린트 적용

This commit is contained in:
chan committed 2026-02-12 10:39:47 +09:00
1 parent 21b9594de5
commit 74884f6616
65 files changed
+26389 -1583

No files matched your search

File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -3,23 +3,23 @@ import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import LoginPage from "../features/auth/LoginPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab";
import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab";
import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab";
import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab";
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
+133 -124
View File
@@ -4,134 +4,143 @@ import {
Key,
KeyRound,
LayoutDashboard,
LayoutGrid,
LogOut,
Moon,
NotebookTabs,
Rocket,
ShieldHalf,
Sun,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{
label: "ui.admin.nav.tenant_dashboard",
to: "/dashboard",
icon: ShieldHalf,
},
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid },
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const handleLogout = () => {
if (window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))) {
window.localStorage.removeItem("admin_session");
navigate("/login");
}
};
useEffect(() => {
const session = window.localStorage.getItem("admin_session");
if (!session) {
navigate("/login");
}
}, [navigate]);
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div>
LayoutGrid,
LogOut,
Moon,
NotebookTabs,
Rocket,
ShieldHalf,
Sun,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{
label: "ui.admin.nav.tenant_dashboard",
to: "/dashboard",
icon: ShieldHalf,
},
{
label: "ui.admin.nav.tenant_groups",
to: "/tenant-groups",
icon: LayoutGrid,
},
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const handleLogout = () => {
if (
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
) {
window.localStorage.removeItem("admin_session");
navigate("/login");
}
};
useEffect(() => {
const session = window.localStorage.getItem("admin_session");
if (!session) {
navigate("/login");
}
}, [navigate]);
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
{t("msg.admin.scope_admin", "Scoped to /admin")}
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
<div className="space-y-1">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.idp_env_prod", "IDP env: prod")}
</span>
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
))}
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
{t("msg.admin.scope_admin", "Scoped to /admin")}
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
<div className="space-y-1">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.idp_env_prod", "IDP env: prod")}
</span>
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
</span>
</div>
<div className="px-3 pt-4 border-t border-border/50">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block"> <p>
</div>
<div className="px-3 pt-4 border-t border-border/50">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
{" "}
<p>
{t(
"msg.admin.notice.scope",
"관리 기능은 /admin 네임스페이스에서만 노출합니다.",
@@ -187,4 +196,4 @@ import {
);
}
export default AppLayout;
export default AppLayout;
+2 -1
View File
@@ -26,7 +26,8 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
+2 -1
View File
@@ -34,7 +34,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
@@ -1,6 +1,6 @@
import { ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ShieldHalf } from "lucide-react";
function AuthCallbackPage() {
const navigate = useNavigate();
@@ -10,7 +10,7 @@ function AuthCallbackPage() {
const token = searchParams.get("token");
if (token) {
window.localStorage.setItem("admin_session", token);
// 만약 팝업창에서 실행 중이라면 부모 창에 알리고 닫기
if (window.opener) {
window.opener.postMessage({ type: "LOGIN_SUCCESS", token }, "*");
@@ -32,7 +32,9 @@ function AuthCallbackPage() {
<ShieldHalf size={32} />
</div>
<div className="text-lg font-semibold">인증 완료 중...</div>
<p className="text-sm text-muted-foreground">세션을 동기화하고 있습니다.</p>
<p className="text-sm text-muted-foreground">
세션을 동기화하고 있습니다.
</p>
</div>
</div>
);
+20 -18
View File
@@ -1,5 +1,5 @@
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react";
import { useState, useEffect } from "react";
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
@@ -32,10 +32,10 @@ function LoginPage() {
const handleSSOLogin = () => {
const userfrontUrl = import.meta.env.USERFRONT_URL || "https://sso.hmac.kr";
const callbackUrl = `${window.location.origin}/auth/callback`;
// 항상 redirect_uri를 포함하여 로그인이 성공하면 콜백 페이지로 오도록 함
const loginUrl = `${userfrontUrl}/signin?source=adminfront&redirect_uri=${encodeURIComponent(callbackUrl)}`;
const width = 500;
const height = 700;
const left = window.screen.width / 2 - width / 2;
@@ -44,7 +44,7 @@ function LoginPage() {
const popup = window.open(
loginUrl,
"BaronSSOLogin",
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`,
);
if (popup) {
@@ -78,18 +78,18 @@ function LoginPage() {
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
관리자 로그인
<LogIn size={20} className="text-primary" />
관리자 로그인
</CardTitle>
<CardDescription>
Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={isLoggingIn}
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={isLoggingIn}
>
{isLoggingIn ? (
<>
@@ -104,22 +104,24 @@ function LoginPage() {
</>
)}
</Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.<br />
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
<br />
민감한 작업 시 재인증을 요구할 수 있습니다.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30"></div>
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
인증 정보가 없거나 로그인이 되지 않는 경우<br />
인증 정보가 없거나 로그인이 되지 않는 경우
<br />
시스템 관리자에게 문의하세요.
</p>
</div>
@@ -127,4 +129,4 @@ function LoginPage() {
);
}
export default LoginPage;
export default LoginPage;
@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { ShieldAlert, CheckCircle2, XCircle, Search } from "lucide-react";
import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import {
@@ -31,9 +31,12 @@ function PermissionChecker() {
const checkMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.get<CheckPermissionResponse>("/v1/admin/debug/check-permission", {
params: { namespace, object, relation, subject },
});
const { data } = await apiClient.get<CheckPermissionResponse>(
"/v1/admin/debug/check-permission",
{
params: { namespace, object, relation, subject },
},
);
return data;
},
});
@@ -48,82 +51,88 @@ function PermissionChecker() {
ReBAC 권한 검증 도구
</CardTitle>
<CardDescription>
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다.
특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory
Keto를 통해 실시간으로 확인합니다.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>Namespace</Label>
<select
value={namespace}
onChange={e => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
<select
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="Tenant">Tenant</option>
<option value="TenantGroup">TenantGroup</option>
<option value="RelyingParty">RelyingParty</option>
<option value="System">System</option>
<option value="Tenant">Tenant</option>
<option value="TenantGroup">TenantGroup</option>
<option value="RelyingParty">RelyingParty</option>
<option value="System">System</option>
</select>
</div>
<div className="space-y-2">
<Label>Relation</Label>
<Input
placeholder="view, manage, admins..."
value={relation}
onChange={e => setRelation(e.target.value)}
<Input
placeholder="view, manage, admins..."
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Object ID</Label>
<Input
placeholder="Tenant UUID 등"
value={object}
onChange={e => setObject(e.target.value)}
<Input
placeholder="Tenant UUID 등"
value={object}
onChange={(e) => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Subject (User:ID)</Label>
<Input
placeholder="User:uuid 또는 Namespace:ID#Relation"
value={subject}
onChange={e => setSubject(e.target.value)}
<Input
placeholder="User:uuid 또는 Namespace:ID#Relation"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
</div>
<div className="flex justify-center">
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
</div>
{checkMutation.isSuccess && (
<div className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
result.allowed ? "bg-green-500/10 border-green-500/50 text-green-600" : "bg-destructive/10 border-destructive/50 text-destructive"
}`}>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center">
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center">
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
</p>
</>
)}
</div>
<div
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
result.allowed
? "bg-green-500/10 border-green-500/50 text-green-600"
: "bg-destructive/10 border-destructive/50 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center">
해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속
포함)
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center">
해당 사용자는 요청한 리소스에 대해 권한이 없습니다.
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { Button } from "../../../components/ui/button";
@@ -10,6 +10,7 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
@@ -18,18 +19,17 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import {
fetchGroupAdmins,
addGroupAdmin,
removeGroupAdmin,
fetchUsers,
type TenantGroupSummary
import {
type TenantGroupSummary,
addGroupAdmin,
fetchGroupAdmins,
fetchUsers,
removeGroupAdmin,
} from "../../../lib/adminApi";
function TenantGroupAdminsTab() {
const { group } = useOutletContext<{
group: TenantGroupSummary;
const { group } = useOutletContext<{
group: TenantGroupSummary;
}>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
@@ -98,21 +98,26 @@ function TenantGroupAdminsTab() {
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
등록된 관리자가 없습니다.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell>
<TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
@@ -129,8 +134,8 @@ function TenantGroupAdminsTab() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
관리자 추가
<UserPlus size={18} className="text-primary" />
관리자 추가
</CardTitle>
</div>
<CardDescription>
@@ -140,11 +145,11 @@ function TenantGroupAdminsTab() {
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
@@ -158,36 +163,47 @@ function TenantGroupAdminsTab() {
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
사용자 이름을 입력하여 검색하세요.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
검색 결과가 없습니다.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
{searchTerm.length >= 2 &&
usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
검색 결과가 없습니다.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
@@ -74,9 +74,9 @@ function TenantGroupCreatePage() {
<Label className="text-sm font-semibold">
Group Name <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 바론소프트웨어 통합그룹"
/>
</div>
@@ -88,7 +88,8 @@ function TenantGroupCreatePage() {
placeholder="baron-group"
/>
<p className="text-xs text-muted-foreground">
URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로 자동 생성됩니다.
URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로
자동 생성됩니다.
</p>
</div>
<div className="space-y-2">
@@ -116,11 +117,12 @@ function TenantGroupCreatePage() {
권한 상속 안내
</CardTitle>
<CardDescription>
테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을 자동으로 가집니다.
테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을
자동으로 가집니다.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
</CardContent>
</Card>
@@ -22,7 +22,10 @@ function TenantGroupDetailPage() {
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenant-groups" className="inline-flex items-center gap-2 hover:text-foreground">
<Link
to="/tenant-groups"
className="inline-flex items-center gap-2 hover:text-foreground"
>
<ArrowLeft size={14} />
Groups
</Link>
@@ -31,14 +34,15 @@ function TenantGroupDetailPage() {
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutGrid size={24} className="text-primary" />
<LayoutGrid size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">
{groupQuery.data?.name ?? "Loading Group..."}
{groupQuery.data?.name ?? "Loading Group..."}
</h2>
</div>
<p className="text-sm text-[var(--color-muted)]">
{groupQuery.data?.description || "그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
{groupQuery.data?.description ||
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
</p>
</div>
<Badge variant="muted">Super Admin only</Badge>
@@ -79,7 +83,9 @@ function TenantGroupDetailPage() {
</div>
<div className="mt-6">
<Outlet context={{ group: groupQuery.data, refetch: groupQuery.refetch }} />
<Outlet
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
/>
</div>
</div>
);
@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Pencil, Plus, RefreshCw, Trash2, LayoutGrid } from "lucide-react";
import { LayoutGrid, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -85,8 +85,8 @@ function TenantGroupListPage() {
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<LayoutGrid size={20} className="text-primary" />
Tenant Group Registry
<LayoutGrid size={20} className="text-primary" />
Tenant Group Registry
</CardTitle>
<CardDescription>
총 {query.data?.total ?? 0}개 그룹
@@ -130,7 +130,7 @@ function TenantGroupListPage() {
<TableCell>{group.slug}</TableCell>
<TableCell>
<Badge variant="secondary">
{group.tenants?.length ?? 0}개
{group.tenants?.length ?? 0}개
</Badge>
</TableCell>
<TableCell>
@@ -13,12 +13,15 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { updateTenantGroup, type TenantGroupSummary } from "../../../lib/adminApi";
import {
type TenantGroupSummary,
updateTenantGroup,
} from "../../../lib/adminApi";
function TenantGroupProfileTab() {
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void;
}>();
const queryClient = useQueryClient();
@@ -44,7 +47,8 @@ function TenantGroupProfileTab() {
<CardHeader>
<CardTitle>그룹 정보 수정</CardTitle>
<CardDescription>
그룹의 기본 이름과 설명을 변경할 수 있습니다. 식별자(Slug)는 변경할 수 없습니다.
그룹의 기본 이름과 설명을 변경할 수 있습니다. 식별자(Slug)는 변경할
수 없습니다.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -83,7 +87,10 @@ function TenantGroupProfileTab() {
<div className="flex justify-end pt-4">
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || (name === group.name && description === group.description)}
disabled={
mutation.isPending ||
(name === group.name && description === group.description)
}
>
{mutation.isPending ? "저장 중..." : "변경사항 저장"}
</Button>
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, Building2, Search } from "lucide-react";
import { Building2, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -10,6 +11,7 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
@@ -18,19 +20,17 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { Badge } from "../../../components/ui/badge";
import {
addTenantToGroup,
removeTenantFromGroup,
fetchTenants,
type TenantGroupSummary
import {
type TenantGroupSummary,
addTenantToGroup,
fetchTenants,
removeTenantFromGroup,
} from "../../../lib/adminApi";
function TenantGroupTenantsTab() {
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void;
}>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
@@ -67,13 +67,15 @@ function TenantGroupTenantsTab() {
}
};
const availableTenants = tenantsQuery.data?.items.filter(
(t) => !group.tenants?.some((gt) => gt.id === t.id)
) || [];
const availableTenants =
tenantsQuery.data?.items.filter(
(t) => !group.tenants?.some((gt) => gt.id === t.id),
) || [];
const filteredAvailable = availableTenants.filter(
(t) => t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTerm.toLowerCase())
(t) =>
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
@@ -101,7 +103,10 @@ function TenantGroupTenantsTab() {
<TableBody>
{group.tenants?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
소속된 테넌트가 없습니다.
</TableCell>
</TableRow>
@@ -111,11 +116,11 @@ function TenantGroupTenantsTab() {
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-xs">{t.slug}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTenant(t.id)}
disabled={removeMutation.isPending}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTenant(t.id)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
@@ -132,17 +137,17 @@ function TenantGroupTenantsTab() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Plus size={18} className="text-primary" />
테넌트 추가
<Plus size={18} className="text-primary" />
테넌트 추가
</CardTitle>
<div className="relative w-48">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
className="pl-8 h-9"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
className="pl-8 h-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<CardDescription>
@@ -161,7 +166,10 @@ function TenantGroupTenantsTab() {
<TableBody>
{filteredAvailable.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
추가할 수 있는 테넌트가 없습니다.
</TableCell>
</TableRow>
@@ -170,19 +178,21 @@ function TenantGroupTenantsTab() {
<TableRow key={t.id}>
<TableCell>
<div className="font-medium">{t.name}</div>
<div className="text-[10px] text-muted-foreground">{t.slug}</div>
<div className="text-[10px] text-muted-foreground">
{t.slug}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{t.status}
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddTenant(t.id)}
disabled={addMutation.isPending}
<Button
variant="outline"
size="sm"
onClick={() => handleAddTenant(t.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
@@ -10,6 +10,7 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
@@ -18,12 +19,11 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import {
fetchTenantAdmins,
addTenantAdmin,
removeTenantAdmin,
fetchUsers
import {
addTenantAdmin,
fetchTenantAdmins,
fetchUsers,
removeTenantAdmin,
} from "../../../lib/adminApi";
function TenantAdminsTab() {
@@ -97,21 +97,26 @@ function TenantAdminsTab() {
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
등록된 관리자가 없습니다.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell>
<TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
@@ -128,8 +133,8 @@ function TenantAdminsTab() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
관리자 추가
<UserPlus size={18} className="text-primary" />
관리자 추가
</CardTitle>
</div>
<CardDescription>
@@ -139,11 +144,11 @@ function TenantAdminsTab() {
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
@@ -157,36 +162,47 @@ function TenantAdminsTab() {
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
사용자 이름을 입력하여 검색하세요.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
검색 결과가 없습니다.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
{searchTerm.length >= 2 &&
usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
검색 결과가 없습니다.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
@@ -45,7 +45,9 @@ function TenantDetailPage() {
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
!isFederationTab && !isAdminTab && !location.pathname.includes("/schema")
!isFederationTab &&
!isAdminTab &&
!location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
@@ -160,7 +160,8 @@ export function TenantProfilePage() {
))}
</select>
<p className="text-xs text-muted-foreground">
테넌트가 속할 그룹을 지정합니다. 그룹 관리자는 소속 테넌트에 대한 접근 권한을 가집니다.
테넌트가 속할 그룹을 지정합니다. 그룹 관리자는 소속 테넌트에 대한
접근 권한을 가집니다.
</p>
</div>
<div className="space-y-2">
+2 -1
View File
@@ -54,7 +54,8 @@
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
background-image:
radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%
+7 -27
View File
@@ -278,7 +278,9 @@ export async function deleteTenantGroup(id: string) {
}
export async function addTenantToGroup(groupId: string, tenantId: string) {
await apiClient.post(`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`);
await apiClient.post(
`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`,
);
}
export async function removeTenantFromGroup(groupId: string, tenantId: string) {
@@ -326,9 +328,7 @@ export async function addGroupAdmin(groupId: string, userId: string) {
}
export async function removeGroupAdmin(groupId: string, userId: string) {
await apiClient.delete(
`/v1/admin/tenant-groups/${groupId}/admins/${userId}`,
);
await apiClient.delete(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
}
// API Key Management (M2M)
@@ -509,15 +509,10 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
}
export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
}
export type RPOwner = {
subject: string;
name?: string;
@@ -525,39 +520,24 @@ export type RPOwner = {
email?: string;
type: string;
};
export async function fetchRPOwners(clientId: string) {
const { data } = await apiClient.get<RPOwner[]>(
`/v1/admin/relying-parties/${clientId}/owners`,
);
return data;
}
export async function addRPOwner(clientId: string, subject: string) {
await apiClient.post(`/v1/admin/relying-parties/${clientId}/owners/${subject}`);
await apiClient.post(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
export async function removeRPOwner(clientId: string, subject: string) {
await apiClient.delete(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
+1 -1
View File
@@ -714,4 +714,4 @@ func main() {
slog.Error("Server failed to start", "error", err)
os.Exit(1)
}
}
}
+13 -13
View File
@@ -68,19 +68,19 @@ type SignupRequest struct {
// User Profile Models
type UserProfileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
}
type UpdateUserRequest struct {
+7 -7
View File
@@ -23,13 +23,13 @@ type Tenant struct {
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (t *Tenant) IsActive() bool {
+1 -1
View File
@@ -968,7 +968,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
// [Added] 사용자가 입력할 간편 코드를 Redis에 저장합니다. (이게 없으면 인증이 안 됩니다)
shortCodePayload, _ := json.Marshal(shortLoginCodePayload{
LoginID: lookupLoginID,
Code: token,
Code: token,
PendingRef: pendingRef,
})
h.RedisService.Set(prefixLoginCodeShort+userCode, string(shortCodePayload), defaultExpiration)
@@ -23,42 +23,52 @@ func (m *MockRPService) Create(ctx context.Context, tenantID string, client doma
args := m.Called(ctx, tenantID, client)
return args.Get(0).(*domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) {
args := m.Called(ctx, clientID)
return args.Get(0).(*domain.RelyingParty), args.Get(1).(*domain.HydraClient), args.Error(2)
}
func (m *MockRPService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) {
args := m.Called(ctx)
return args.Get(0).([]domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) {
args := m.Called(ctx, tenantIDs)
return args.Get(0).([]domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) {
args := m.Called(ctx, clientID, client)
return args.Get(0).(*domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) Delete(ctx context.Context, clientID string) error {
args := m.Called(ctx, clientID)
return args.Error(0)
}
func (m *MockRPService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) {
args := m.Called(ctx, userID, clientID, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockRPService) AddOwner(ctx context.Context, clientID, subject string) error {
args := m.Called(ctx, clientID, subject)
return args.Error(0)
}
func (m *MockRPService) RemoveOwner(ctx context.Context, clientID, subject string) error {
args := m.Called(ctx, clientID, subject)
return args.Error(0)
}
func (m *MockRPService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
args := m.Called(ctx, clientID)
return args.Get(0).([]string), args.Error(1)
@@ -18,14 +18,14 @@ func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.Krat
}
type tenantGroupSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Tenants []tenantSummary `json:"tenants,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {
@@ -23,16 +23,20 @@ func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespac
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1)
+8
View File
@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// Fixed MockKetoService to match service.KetoService exactly if possible.
// Wait, middleware/rbac.go imports baron-sso-backend/internal/service.
// So I should use service.RelationTuple.
@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// --- Test Helpers ---
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)
@@ -17,9 +17,11 @@ type MockTenantRepository struct {
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0)
}
func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0)
}
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
@@ -27,50 +29,31 @@ func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
args := m.Called(ctx, slug)
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
args := m.Called(ctx, name)
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
args := m.Called(ctx, domainName)
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
args := m.Called(ctx, ids)
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
return m.Called(ctx, tenantID, domainName).Error(0)
}
// MockKetoService is a mock implementation of KetoService
type MockKetoService struct {
mock.Mock
}
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1)
}
func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) {
mockRepo := new(MockTenantRepository)
mockKeto := new(MockKetoService)
+2 -1
View File
@@ -26,7 +26,8 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
+2 -1
View File
@@ -34,7 +34,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
+2 -1
View File
@@ -54,7 +54,8 @@
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
background-image:
radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%
@@ -11,7 +11,8 @@ class AuditService {
return dotenv.env[key] ?? fallback;
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static String get _baseUrl =>
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static Future<void> logEvent({
required String userId,
@@ -20,7 +21,7 @@ class AuditService {
String? details,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/audit');
try {
final response = await http.post(
url,
@@ -19,10 +19,12 @@ class AuthProxyService {
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
static bool get _isProd {
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
return env == 'prod' || env == 'production';
}
static bool get isProdEnv => _isProd;
static bool _shouldSendDrySend(bool? drySend) {
if (_isProd) {
@@ -76,13 +78,14 @@ class AuthProxyService {
}
}
static Future<int> getSessionStatus({String? token, bool useCookie = false}) async {
static Future<int> getSessionStatus({
String? token,
bool useCookie = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
@@ -101,11 +104,8 @@ class AuthProxyService {
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
final body = <String, dynamic>{
'loginId': loginId,
'uri': userfrontUrl,
};
final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
if (_shouldSendDrySend(drySend)) {
body['drySend'] = true;
}
@@ -115,7 +115,7 @@ class AuthProxyService {
if (codeOnly == true) {
body['codeOnly'] = true;
}
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -133,15 +133,15 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async {
static Future<Map<String, dynamic>> pollEnchantedLink(
String pendingRef,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'pendingRef': pendingRef,
}),
body: jsonEncode({'pendingRef': pendingRef}),
);
if (response.statusCode == 200) {
@@ -157,16 +157,16 @@ class AuthProxyService {
);
}
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async {
static Future<Map<String, dynamic>> verifyMagicLink(
String token, {
bool verifyOnly = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'token': token,
'verifyOnly': verifyOnly,
}),
body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}),
);
if (response.statusCode == 200) {
@@ -223,10 +223,7 @@ class AuthProxyService {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'shortCode': shortCode,
'verifyOnly': verifyOnly,
}),
body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}),
);
if (response.statusCode == 200) {
@@ -240,13 +237,18 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password, {String? loginChallenge}) async {
static Future<Map<String, dynamic>> loginWithPassword(
String loginId,
String password, {
String? loginChallenge,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
final payload = {
'loginId': loginId,
'password': password,
if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge,
if (loginChallenge != null && loginChallenge.isNotEmpty)
'login_challenge': loginChallenge,
};
final response = await http.post(
@@ -272,8 +274,13 @@ class AuthProxyService {
);
}
}
static Future<Map<String, dynamic>> getConsentInfo(String consentChallenge) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent').replace(queryParameters: {'consent_challenge': consentChallenge});
static Future<Map<String, dynamic>> getConsentInfo(
String consentChallenge,
) async {
final url = Uri.parse(
'$_baseUrl/api/v1/auth/consent',
).replace(queryParameters: {'consent_challenge': consentChallenge});
final response = await http.get(
url,
headers: {'Content-Type': 'application/json'},
@@ -293,11 +300,12 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> acceptConsent(String consentChallenge, {List<String>? grantScope}) async {
static Future<Map<String, dynamic>> acceptConsent(
String consentChallenge, {
List<String>? grantScope,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
final body = <String, dynamic>{
'consent_challenge': consentChallenge,
};
final body = <String, dynamic>{'consent_challenge': consentChallenge};
if (grantScope != null) {
body['grant_scope'] = grantScope;
}
@@ -322,11 +330,11 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> rejectConsent(String consentChallenge) async {
static Future<Map<String, dynamic>> rejectConsent(
String consentChallenge,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
final body = <String, dynamic>{
'consent_challenge': consentChallenge,
};
final body = <String, dynamic>{'consent_challenge': consentChallenge};
final response = await http.post(
url,
@@ -353,9 +361,7 @@ class AuthProxyService {
String? token,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
@@ -364,9 +370,7 @@ class AuthProxyService {
final response = await client.post(
url,
headers: headers,
body: jsonEncode({
'login_challenge': loginChallenge,
}),
body: jsonEncode({'login_challenge': loginChallenge}),
);
if (response.statusCode == 200) {
@@ -386,8 +390,10 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async {
static Future<Map<String, dynamic>> initiatePasswordReset(
String loginId, {
bool? drySend,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post(
url,
@@ -424,7 +430,9 @@ class AuthProxyService {
if (token != null && token.isNotEmpty) {
query['token'] = token;
}
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query);
final url = Uri.parse(
'$_baseUrl/api/v1/auth/password/reset/complete',
).replace(queryParameters: query);
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -447,13 +455,11 @@ class AuthProxyService {
static Future<void> sendSms(String phoneNumber) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
}),
body: jsonEncode({'phoneNumber': phoneNumber}),
);
if (response.statusCode != 200) {
@@ -465,16 +471,16 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> verifySmsCode(String phoneNumber, String code) async {
static Future<Map<String, dynamic>> verifySmsCode(
String phoneNumber,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
'code': code,
}),
body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}),
);
if (response.statusCode == 200) {
@@ -532,10 +538,10 @@ class AuthProxyService {
String? token,
bool withCredentials = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
final payload = <String, dynamic>{
'pendingRef': pendingRef,
};
final url = Uri.parse(
'$_baseUrl/api/v1/auth/qr/approve',
); // Mapping to ScanQRLogin on backend
final payload = <String, dynamic>{'pendingRef': pendingRef};
if (token != null && token.isNotEmpty) {
payload['token'] = token;
}
@@ -593,7 +599,7 @@ class AuthProxyService {
String? displayName,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/users');
final response = await http.post(
url,
headers: {
@@ -617,7 +623,10 @@ class AuthProxyService {
}
}
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async {
static Future<List<dynamic>> listUsers(
String adminPassword, {
String? query,
}) async {
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
if (query != null && query.isNotEmpty) {
uri = uri.replace(queryParameters: {'text': query});
@@ -646,7 +655,7 @@ class AuthProxyService {
static Future<void> deleteUser(String adminPassword, String loginId) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final response = await http.delete(
url,
headers: {
@@ -664,10 +673,14 @@ class AuthProxyService {
}
}
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async {
static Future<void> updateUserStatus(
String adminPassword,
String loginId,
String status,
) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
final response = await http.patch(
url,
headers: {
@@ -695,7 +708,7 @@ class AuthProxyService {
}) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final body = <String, dynamic>{};
if (email != null) body['email'] = email;
if (phone != null) body['phone'] = phone;
@@ -725,18 +738,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
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,
);
final response = await client.get(url, headers: headers);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
@@ -758,18 +766,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.delete(
url,
headers: headers,
);
final response = await client.delete(url, headers: headers);
if (response.statusCode != 200) {
final errorBody = jsonDecode(response.body);
@@ -786,7 +789,11 @@ class AuthProxyService {
}
}
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
static Future<void> sendLog(
String level,
String message, {
Map<String, dynamic>? data,
}) async {
if (!_canSendClientLog()) {
return;
}
@@ -808,11 +815,15 @@ class AuthProxyService {
}
}
static Future<void> logError(String message, {dynamic error, StackTrace? stackTrace}) async {
static Future<void> logError(
String message, {
dynamic error,
StackTrace? stackTrace,
}) async {
final data = <String, dynamic>{};
if (error != null) data['error'] = error.toString();
if (stackTrace != null) data['stack'] = stackTrace.toString();
await sendLog('ERROR', message, data: data);
}
@@ -861,7 +872,7 @@ class AuthProxyService {
static Future<void> sendSignupCode(String target, String type) async {
final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -877,17 +888,17 @@ class AuthProxyService {
}
}
static Future<bool> verifySignupCode(String target, String type, String code) async {
static Future<bool> verifySignupCode(
String target,
String type,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'target': target,
'type': type,
'code': code,
}),
body: jsonEncode({'target': target, 'type': type, 'code': code}),
);
if (response.statusCode == 200) {
@@ -908,7 +919,7 @@ class AuthProxyService {
required bool termsAccepted,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
+1 -2
View File
@@ -1,6 +1,5 @@
import 'package:http/http.dart' as http;
import 'http_client_stub.dart'
if (dart.library.html) 'http_client_web.dart';
import 'http_client_stub.dart' if (dart.library.html) 'http_client_web.dart';
http.Client createHttpClient({bool withCredentials = false}) {
return httpClientFactory.create(withCredentials: withCredentials);
@@ -25,8 +25,10 @@ class LoggerService {
);
// 2. Configure Standard Logger (logging package)
std_log.Logger.root.level = kReleaseMode ? std_log.Level.WARNING : std_log.Level.ALL;
std_log.Logger.root.level = kReleaseMode
? std_log.Level.WARNING
: std_log.Level.ALL;
std_log.Logger.root.onRecord.listen((record) {
if (kReleaseMode) {
// [Production] Log as JSON
@@ -41,13 +43,17 @@ class LoggerService {
/// Initialize the logger. Call this in main.dart
static void init() {
// Accessing the instance triggers the constructor
LoggerService();
LoggerService();
std_log.Logger('BaronSSO').info('Logger initialized');
}
void _logPretty(std_log.LogRecord record) {
if (record.level >= std_log.Level.SEVERE) {
_prettyLogger.e(record.message, error: record.error, stackTrace: record.stackTrace);
_prettyLogger.e(
record.message,
error: record.error,
stackTrace: record.stackTrace,
);
} else if (record.level >= std_log.Level.WARNING) {
_prettyLogger.w(record.message);
} else if (record.level >= std_log.Level.INFO) {
@@ -66,7 +72,7 @@ class LoggerService {
if (record.error != null) 'error': record.error.toString(),
if (record.stackTrace != null) 'stack': record.stackTrace.toString(),
};
// 1. Print to Browser Console (F12)
debugPrint(jsonEncode(logData));
@@ -13,16 +13,22 @@ void implSendLoginSuccess(String token) {
final fullUrl = html.window.location.href;
final uri = Uri.base;
// Try to find redirect_uri from standard parsing first, then manual string search
String? redirectUri = uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url'];
String? redirectUri =
uri.queryParameters['redirect_uri'] ??
uri.queryParameters['redirect_url'];
if (redirectUri == null) {
// Manual fallback for cases where Uri.base misses params
final searchParams = html.window.location.search;
if (searchParams != null && searchParams.isNotEmpty) {
final sUri = Uri.parse('?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}');
redirectUri = sUri.queryParameters['redirect_uri'] ?? sUri.queryParameters['redirect_url'];
final sUri = Uri.parse(
'?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}',
);
redirectUri =
sUri.queryParameters['redirect_uri'] ??
sUri.queryParameters['redirect_url'];
}
}
@@ -48,14 +54,14 @@ void implSendLoginSuccess(String token) {
final query = Map<String, String>.from(target.queryParameters);
query['token'] = effectiveToken;
final finalUri = target.replace(queryParameters: query);
debugPrint('Redirecting to: ${finalUri.toString()}');
html.window.location.href = finalUri.toString();
return;
}
final message = {'type': 'LOGIN_SUCCESS', 'token': effectiveToken};
if (html.window.opener != null) {
try {
html.window.opener!.postMessage(message, '*');
@@ -63,20 +69,20 @@ void implSendLoginSuccess(String token) {
} catch (e) {
debugPrint('Failed to postMessage: $e');
}
// Close the popup after a short delay to ensure message sending
Timer(const Duration(milliseconds: 500), () {
html.window.close();
});
} else {
// Should not happen given isPopup check, but as fallback:
// Should not happen given isPopup check, but as fallback:
debugPrint('No opener found during popup flow.');
}
}
bool implIsPopup() {
if (html.window.opener != null) return true;
// Fallback: Check query parameters for integration source
final uri = Uri.base;
if (uri.queryParameters['source'] == 'adminfront') return true;
@@ -15,7 +15,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
bool _isLoading = false;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
@@ -28,7 +28,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
// Show blocking dialog
final String? inputPassword = await showDialog<String>(
context: context,
@@ -86,7 +86,10 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red),
const SnackBar(
content: Text('Invalid Password. Access Denied.'),
backgroundColor: Colors.red,
),
);
context.go('/'); // Kick out
}
@@ -116,7 +119,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
}
}
String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim();
String? phone = _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
@@ -128,14 +133,21 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
await AuthProxyService.createUser(
loginId: loginId,
adminPassword: _verifiedAdminPassword!,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
email: _emailController.text.trim().isEmpty
? null
: _emailController.text.trim(),
phone: phone,
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
displayName: _nameController.text.trim().isEmpty
? null
: _nameController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green),
const SnackBar(
content: Text('User created successfully!'),
backgroundColor: Colors.green,
),
);
_formKey.currentState!.reset();
_loginIdController.clear();
@@ -158,9 +170,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
Widget build(BuildContext context) {
// Hide content until authorized
if (!_isAuthorized) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
@@ -186,7 +196,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _loginIdController,
decoration: const InputDecoration(
@@ -194,10 +204,12 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)",
),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
validator: (value) => value == null || value.isEmpty
? 'Please enter Login ID'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
@@ -207,7 +219,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
@@ -217,7 +229,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
@@ -228,14 +240,14 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _submit,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text("Create User"),
),
],
@@ -245,4 +257,4 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
);
}
}
}
@@ -10,7 +10,8 @@ class UserManagementScreen extends StatefulWidget {
State<UserManagementScreen> createState() => _UserManagementScreenState();
}
class _UserManagementScreenState extends State<UserManagementScreen> with SingleTickerProviderStateMixin {
class _UserManagementScreenState extends State<UserManagementScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
@@ -23,7 +24,8 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- Create Tab Controllers ---
final _formKey = GlobalKey<FormState>();
final TextEditingController _createLoginIdController = TextEditingController();
final TextEditingController _createLoginIdController =
TextEditingController();
final TextEditingController _createEmailController = TextEditingController();
final TextEditingController _createPhoneController = TextEditingController();
final TextEditingController _createNameController = TextEditingController();
@@ -50,7 +52,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- Authentication ---
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
final String? inputPassword = await showDialog<String>(
context: context,
barrierDismissible: false,
@@ -64,15 +66,24 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: "Password", border: OutlineInputBorder()),
decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
),
autofocus: true,
onSubmitted: (value) => Navigator.pop(context, value),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, passwordController.text), child: const Text("Enter")),
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, passwordController.text),
child: const Text("Enter"),
),
],
),
);
@@ -96,7 +107,12 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid Password'),
backgroundColor: Colors.red,
),
);
context.go('/');
}
}
@@ -107,7 +123,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
if (_verifiedAdminPassword == null) return;
setState(() => _isLoading = true);
try {
final users = await AuthProxyService.listUsers(_verifiedAdminPassword!, query: query);
final users = await AuthProxyService.listUsers(
_verifiedAdminPassword!,
query: query,
);
setState(() => _users = users);
} catch (e) {
_showError("Failed to load users: $e");
@@ -125,18 +144,23 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
Future<void> _deleteUser(String loginId) async {
if (_verifiedAdminPassword == null) return;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Delete User"),
content: Text("Are you sure you want to delete $loginId? This cannot be undone."),
content: Text(
"Are you sure you want to delete $loginId? This cannot be undone.",
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text("Delete")
onPressed: () => Navigator.pop(context, true),
child: const Text("Delete"),
),
],
),
@@ -158,11 +182,17 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
Future<void> _toggleStatus(String loginId, String currentStatus) async {
if (_verifiedAdminPassword == null) return;
final newStatus = (currentStatus == "enabled" || currentStatus == "active") ? "disabled" : "enabled";
final newStatus = (currentStatus == "enabled" || currentStatus == "active")
? "disabled"
: "enabled";
setState(() => _isLoading = true);
try {
await AuthProxyService.updateUserStatus(_verifiedAdminPassword!, loginId, newStatus);
await AuthProxyService.updateUserStatus(
_verifiedAdminPassword!,
loginId,
newStatus,
);
_showSuccess("User status updated to $newStatus");
_loadUsers(query: _searchController.text);
} catch (e) {
@@ -174,14 +204,20 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
Future<void> _editUser(Map user) async {
if (_verifiedAdminPassword == null) return;
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
if (loginId.isEmpty) return;
final nameController = TextEditingController(text: user['name'] ?? user['user']?['name'] ?? "");
final emailController = TextEditingController(text: user['user']?['email'] ?? "");
final phoneController = TextEditingController(text: user['user']?['phone'] ?? "");
final nameController = TextEditingController(
text: user['name'] ?? user['user']?['name'] ?? "",
);
final emailController = TextEditingController(
text: user['user']?['email'] ?? "",
);
final phoneController = TextEditingController(
text: user['user']?['phone'] ?? "",
);
final confirm = await showDialog<bool>(
context: context,
@@ -190,14 +226,29 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: "Name")),
TextField(controller: emailController, decoration: const InputDecoration(labelText: "Email")),
TextField(controller: phoneController, decoration: const InputDecoration(labelText: "Phone")),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: "Name"),
),
TextField(
controller: emailController,
decoration: const InputDecoration(labelText: "Email"),
),
TextField(
controller: phoneController,
decoration: const InputDecoration(labelText: "Phone"),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text("Save")),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("Save"),
),
],
),
);
@@ -206,7 +257,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
setState(() => _isLoading = true);
String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
String? phone = phoneController.text.trim().isEmpty
? null
: phoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
@@ -246,7 +299,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
}
}
String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim();
String? phone = _createPhoneController.text.trim().isEmpty
? null
: _createPhoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
@@ -258,9 +313,13 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
await AuthProxyService.createUser(
loginId: loginId,
adminPassword: _verifiedAdminPassword!,
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
email: _createEmailController.text.trim().isEmpty
? null
: _createEmailController.text.trim(),
phone: phone,
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(),
displayName: _createNameController.text.trim().isEmpty
? null
: _createNameController.text.trim(),
);
_showSuccess("User created successfully");
@@ -269,11 +328,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
_createEmailController.clear();
_createPhoneController.clear();
_createNameController.clear();
// Switch to list tab and reload
_tabController.animateTo(0);
_loadUsers();
} catch (e) {
_showError("Error: $e");
} finally {
@@ -284,12 +342,16 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- UI Helpers ---
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
}
void _showSuccess(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
}
@override
@@ -315,10 +377,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
),
body: TabBarView(
controller: _tabController,
children: [
_buildUserListTab(),
_buildCreateUserTab(),
],
children: [_buildUserListTab(), _buildCreateUserTab()],
),
);
}
@@ -341,32 +400,49 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
),
),
Expanded(
child: _users.isEmpty
? const Center(child: Text("No users found."))
child: _users.isEmpty
? const Center(child: Text("No users found."))
: ListView.separated(
itemCount: _users.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final user = _users[index];
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID";
final name = user['name'] ?? user['user']?['name'] ?? "No Name";
final loginId = loginIDs.isNotEmpty
? loginIDs.first.toString()
: "Unknown ID";
final name =
user['name'] ?? user['user']?['name'] ?? "No Name";
final status = user['status'] ?? "unknown";
final isEnabled = status == "enabled" || status == "active";
return ListTile(
leading: CircleAvatar(
backgroundColor: isEnabled ? Colors.green.shade100 : Colors.grey.shade300,
child: Icon(Icons.person, color: isEnabled ? Colors.green : Colors.grey),
backgroundColor: isEnabled
? Colors.green.shade100
: Colors.grey.shade300,
child: Icon(
Icons.person,
color: isEnabled ? Colors.green : Colors.grey,
),
),
title: Text(name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loginId, style: const TextStyle(fontWeight: FontWeight.bold)),
Text("Status: $status", style: TextStyle(color: isEnabled ? Colors.green : Colors.red, fontSize: 12)),
Text(
loginId,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
"Status: $status",
style: TextStyle(
color: isEnabled ? Colors.green : Colors.red,
fontSize: 12,
),
),
],
),
trailing: Row(
@@ -378,7 +454,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
onPressed: () => _editUser(user),
),
IconButton(
icon: Icon(isEnabled ? Icons.block : Icons.check_circle, color: isEnabled ? Colors.orange : Colors.green),
icon: Icon(
isEnabled ? Icons.block : Icons.check_circle,
color: isEnabled ? Colors.orange : Colors.green,
),
tooltip: isEnabled ? "Disable User" : "Enable User",
onPressed: () => _toggleStatus(loginId, status),
),
@@ -417,27 +496,44 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)",
),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
validator: (value) => value == null || value.isEmpty
? 'Please enter Login ID'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _createNameController,
decoration: const InputDecoration(labelText: "Display Name", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person)),
decoration: const InputDecoration(
labelText: "Display Name",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _createEmailController,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)),
decoration: const InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _createPhoneController,
decoration: const InputDecoration(labelText: "Phone Number", border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone), helperText: "010-xxxx-xxxx"),
decoration: const InputDecoration(
labelText: "Phone Number",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
helperText: "010-xxxx-xxxx",
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _createUserSubmit,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text("Create User"),
),
],
@@ -134,13 +134,16 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 40),
if (_message != null)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
_message!,
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
style: TextStyle(
color: _success ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
@@ -155,7 +158,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
backgroundColor: Colors.blue,
),
),
if (!isLoggedIn && !_success)
Padding(
padding: const EdgeInsets.only(top: 16),
@@ -17,7 +17,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
bool _isLoading = true;
bool _isSubmitting = false;
String? _error;
// 사용자가 선택한 스코프 목록
final Set<String> _selectedScopes = {};
@@ -41,8 +41,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async {
try {
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
final info = await AuthProxyService.getConsentInfo(
widget.consentChallenge,
);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) {
webWindow.redirectTo(info['redirectTo']);
@@ -52,19 +54,20 @@ class _ConsentScreenState extends State<ConsentScreen> {
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
if (info['scope_details'] != null) {
final details = info['scope_details'] as Map<String, dynamic>;
details.forEach((scope, detail) {
if (detail is Map<String, dynamic>) {
// 설명 업데이트
if (detail['description'] != null && detail['description'].toString().isNotEmpty) {
if (detail['description'] != null &&
detail['description'].toString().isNotEmpty) {
_scopeDescriptions[scope] = detail['description'].toString();
}
// 필수 여부 업데이트
if (detail['mandatory'] == true) {
_mandatoryScopes.add(scope);
} else {
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
if (scope != 'openid') {
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
}
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
final requestedScopes =
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
_selectedScopes.addAll(requestedScopes);
setState(() {
@@ -102,7 +106,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
widget.consentChallenge,
grantScope: _selectedScopes.toList(),
);
if (result['redirectTo'] != null) {
webWindow.redirectTo(result['redirectTo']);
} else {
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
if (confirmed == true) {
setState(() => _isSubmitting = true);
try {
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
final resp = await AuthProxyService.rejectConsent(
widget.consentChallenge,
);
final redirectTo = resp['redirectTo'];
if (redirectTo != null) {
webWindow.redirectTo(redirectTo);
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
} catch (e) {
setState(() => _isSubmitting = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
}
}
}
@@ -164,13 +170,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
Widget build(BuildContext context) {
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
return Scaffold(
backgroundColor: Colors.grey[100],
backgroundColor: Colors.grey[100],
body: Center(
child: _isLoading
? const CircularProgressIndicator()
: _error != null
? _buildErrorCard()
: _buildConsentCard(context),
? _buildErrorCard()
: _buildConsentCard(context),
),
);
}
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
final clientLogo = _consentInfo?['client']?['logo_uri'];
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
final requestedScopes =
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
[];
return SingleChildScrollView(
child: Container(
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
margin: const EdgeInsets.all(16),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
),
child: Row(
children: [
if (clientLogo != null && clientLogo.toString().isNotEmpty)
if (clientLogo != null &&
clientLogo.toString().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 16),
child: CircleAvatar(
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
children: [
const Text(
'요청된 권한',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'총 ${requestedScopes.length}개',
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
)
: const Text(
'동의하고 계속하기',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
@@ -32,30 +32,36 @@ class ErrorScreen extends StatelessWidget {
final title = isProd
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
: (hasCode
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr(
'msg.userfront.error.title_generic',
fallback: '오류가 발생했습니다',
));
final detail = isProd
? (isWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
: ((description?.isNotEmpty == true)
? description!
: (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
? description!
: (hasCode
? tr(
'msg.userfront.error.detail_generic',
fallback: '오류가 발생했습니다.',
)
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF111827),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
tr(
'ui.userfront.error.go_login',
fallback: '로그인으로 이동',
),
),
),
OutlinedButton(
onPressed: () => context.go('/'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: const BorderSide(color: Color(0xFFCBD5F5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
@@ -17,7 +17,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
@override
void initState() {
super.initState();
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
_drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
}
Future<void> _handlePasswordReset() async {
@@ -44,7 +46,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
setState(() => _isLoading = true);
try {
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
await AuthProxyService.initiatePasswordReset(
loginId,
drySend: _drySendEnabled,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -107,16 +112,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
children: [
Text(
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
if (_drySendEnabled) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
@@ -124,7 +129,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -132,7 +140,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
'msg.userfront.forgot.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
style: const TextStyle(
color: Color(0xFF8A6D3B),
fontSize: 12,
),
),
),
],
@@ -172,13 +183,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
tr(
'ui.userfront.forgot.submit',
fallback: '재설정 링크 전송',
),
tr('ui.userfront.forgot.submit', fallback: '재설정 링크 전송'),
),
),
],
@@ -18,7 +18,12 @@ class LoginScreen extends ConsumerStatefulWidget {
final String? loginChallenge;
final String? redirectUrl;
const LoginScreen({super.key, this.verificationToken, this.loginChallenge, this.redirectUrl});
const LoginScreen({
super.key,
this.verificationToken,
this.loginChallenge,
this.redirectUrl,
});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
@@ -28,7 +33,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _linkIdController = TextEditingController();
final TextEditingController _passwordLoginIdController = TextEditingController();
final TextEditingController _passwordLoginIdController =
TextEditingController();
final TextEditingController _passwordController = TextEditingController();
String? _redirectUrl;
String? _loginChallenge;
@@ -41,8 +47,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
int _qrRemainingSeconds = 0;
Timer? _qrCountdownTimer;
int _qrPollIntervalMs = 2000;
final TextEditingController _shortCodePrefixController = TextEditingController();
final TextEditingController _shortCodeDigitsController = TextEditingController();
final TextEditingController _shortCodePrefixController =
TextEditingController();
final TextEditingController _shortCodeDigitsController =
TextEditingController();
String? _linkPendingRef;
String? _lastLinkLoginId;
bool _lastLinkIsEmail = true;
@@ -75,12 +83,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
super.initState();
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabSelection);
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
_drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
_redirectUrl = widget.redirectUrl;
WidgetsBinding.instance.addPostFrameCallback((_) async {
final uri = Uri.base;
if (_redirectUrl == null) {
if (uri.queryParameters.containsKey('redirect_url')) {
_redirectUrl = uri.queryParameters['redirect_url'];
@@ -89,15 +99,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
_loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge'];
_loginChallenge =
widget.loginChallenge ?? uri.queryParameters['login_challenge'];
final loginIdParam = uri.queryParameters['loginId'];
final codeParam = uri.queryParameters['code'];
final pendingRefParam = uri.queryParameters['pendingRef'];
final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
final hasShortCodePath =
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
final hasTokenParam = uri.queryParameters.containsKey('t');
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
final hasVerificationToken =
widget.verificationToken != null || hasTokenParam;
final hasLoginCode = loginIdParam != null && codeParam != null;
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
_verificationOnly =
hasVerificationToken || hasLoginCode || hasShortCodePath;
final notice = uri.queryParameters['notice'];
if (hasShortCodePath) {
@@ -150,9 +164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr(
'msg.userfront.login.cookie_check_failed',
fallback: '로그인 확인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
params: {'error': e.toString().replaceFirst('Exception: ', '')},
),
);
}
@@ -171,8 +183,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final token = AuthTokenStore.getToken();
if (token != null && token.isNotEmpty) {
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint("[Auth] Cookie session with external integration. Notifying...");
if (WebAuthIntegration.isPopup() ||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint(
"[Auth] Cookie session with external integration. Notifying...",
);
WebAuthIntegration.sendLoginSuccess(token);
return;
}
@@ -200,7 +215,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
try {
await AuthProxyService.checkCookieSession();
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory');
AuthTokenStore.setCookieMode(
provider: AuthTokenStore.getProvider() ?? 'ory',
);
await _acceptOidcLoginAndRedirect();
} catch (e) {
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
@@ -289,7 +306,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final parts = jwt.split('.');
if (parts.length != 3) return 'User';
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload);
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
} catch (e) {
@@ -360,65 +379,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _startQrPolling() {
_qrPollingTimer?.cancel();
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
timer.cancel();
return;
}
_qrPollingTimer = Timer.periodic(
Duration(milliseconds: _qrPollIntervalMs),
(timer) async {
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
timer.cancel();
return;
}
try {
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
if (res['error'] == 'slow_down') {
final interval = res['interval'];
if (interval is int && interval > 0) {
final nextIntervalMs = interval * 1000;
if (nextIntervalMs != _qrPollIntervalMs) {
_qrPollIntervalMs = nextIntervalMs;
try {
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
if (res['error'] == 'slow_down') {
final interval = res['interval'];
if (interval is int && interval > 0) {
final nextIntervalMs = interval * 1000;
if (nextIntervalMs != _qrPollIntervalMs) {
_qrPollIntervalMs = nextIntervalMs;
timer.cancel();
_startQrPolling();
return;
}
} else {
_qrPollIntervalMs += 500;
timer.cancel();
_startQrPolling();
return;
}
} else {
_qrPollIntervalMs += 500;
timer.cancel();
_startQrPolling();
}
if (res['error'] == 'authorization_pending') {
return;
}
}
if (res['error'] == 'authorization_pending') {
return;
}
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError(
tr(
'msg.userfront.login.qr_expired',
fallback: 'QR 세션이 만료되었습니다.',
),
);
return;
}
if (res['status'] == 'ok') {
timer.cancel();
_qrCountdownTimer?.cancel();
final token = res['sessionJwt'] ?? res['token'];
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
tr('msg.userfront.login.qr_expired', fallback: 'QR 세션이 만료되었습니다.'),
);
return;
}
if (res['status'] == 'ok') {
timer.cancel();
_qrCountdownTimer?.cancel();
final token = res['sessionJwt'] ?? res['token'];
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
}
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
}
});
},
);
}
void _stopQrPolling() {
@@ -486,21 +505,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Duration redirectDelay = const Duration(seconds: 2),
}) {
if (!mounted) return;
final resolvedTitle = title ??
tr(
'ui.userfront.login.verification.title',
fallback: '승인 완료',
);
final resolvedPageTitle = pageTitle ??
tr(
'ui.userfront.login.verification.page_title',
fallback: '로그인 승인',
);
final resolvedActionLabel = actionLabel ??
tr(
'ui.userfront.login.verification.action_label',
fallback: '확인',
);
final resolvedTitle =
title ?? tr('ui.userfront.login.verification.title', fallback: '승인 완료');
final resolvedPageTitle =
pageTitle ??
tr('ui.userfront.login.verification.page_title', fallback: '로그인 승인');
final resolvedActionLabel =
actionLabel ??
tr('ui.userfront.login.verification.action_label', fallback: '확인');
setState(() {
_verificationApproved = true;
_verificationMessage = message;
@@ -524,11 +536,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
const Icon(
Icons.check_circle_outline,
color: Colors.green,
size: 72,
),
const SizedBox(height: 16),
Text(
_verificationTitle,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 12),
Text(
@@ -544,7 +564,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 24),
FilledButton(
onPressed: () {
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
final hasLocalSession =
AuthTokenStore.getToken() != null ||
AuthTokenStore.usesCookie();
final target = hasLocalSession ? '/' : '/signin';
if (mounted) {
setState(() {
@@ -586,10 +608,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -602,18 +621,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
return;
}
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -629,9 +642,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
Future<void> _verifyLoginCode(
String loginId,
String code, {
String? pendingRef,
}) async {
final sanitizedLoginId = loginId.replaceAll(' ', '+');
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
debugPrint(
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
);
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
@@ -653,16 +672,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
final jwt = res['sessionJwt'] ?? res['token'];
final status = res['status']?.toString();
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
debugPrint(
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
);
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -676,18 +694,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (_verificationOnly) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
_markVerificationApproved(
linkLoginMessage,
title: tr(
'ui.userfront.login.link.title',
fallback: '링크 로그인 완료',
),
title: tr('ui.userfront.login.link.title', fallback: '링크 로그인 완료'),
pageTitle: tr(
'ui.userfront.login.link.page_title',
fallback: '링크 로그인',
@@ -703,13 +715,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
debugPrint(
"[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e",
);
if (mounted) {
_showError(
tr(
@@ -747,10 +758,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -764,10 +772,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (_verificationOnly) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
@@ -775,10 +780,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e");
@@ -836,7 +838,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
try {
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge);
final res = await AuthProxyService.loginWithPassword(
loginId,
password,
loginChallenge: _loginChallenge,
);
final jwt = res['sessionJwt'];
final provider = res['provider'] as String?;
final redirectTo = res['redirectTo'] as String?;
@@ -860,9 +866,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr(
'msg.userfront.login.password.failed',
fallback: '로그인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
params: {'error': e.toString().replaceFirst('Exception: ', '')},
),
);
}
@@ -900,13 +904,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async {
Future<void> _startEnchantedFlow(
String loginId, {
required bool isEmail,
bool codeOnly = false,
}) async {
try {
if (mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
}
@@ -921,7 +930,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final interval = initResponse['interval'];
final resendAfter = initResponse['resendAfter'];
final expiresIn = initResponse['expiresIn'];
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
debugPrint(
"[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider",
);
if (mounted) {
setState(() {
@@ -974,7 +985,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _pollForSession(String pendingRef, {Duration? initialInterval}) async {
Future<void> _pollForSession(
String pendingRef, {
Duration? initialInterval,
}) async {
int attempts = 0;
const maxAttempts = 60;
var pollInterval = initialInterval ?? const Duration(seconds: 2);
@@ -1047,10 +1061,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
Navigator.of(context).pop();
_showError(
tr(
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
tr('msg.userfront.login.link_timeout', fallback: '로그인 요청 시간이 초과되었습니다.'),
);
}
}
@@ -1138,10 +1149,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint("[Auth] External integration detected (popup or redirect). Notifying...");
if (WebAuthIntegration.isPopup() ||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint(
"[Auth] External integration detected (popup or redirect). Notifying...",
);
WebAuthIntegration.sendLoginSuccess(token);
return;
return;
}
debugPrint("[Auth] Login success. Navigating to root.");
@@ -1224,7 +1238,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_drySendEnabled) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
@@ -1232,13 +1249,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8),
Expanded(
child: Text(
tr(
'msg.userfront.login.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
fallback:
'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(
color: Color(0xFF8A6D3B),
@@ -1294,7 +1315,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
prefixIcon: const Icon(
Icons.person_outline,
),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1308,7 +1331,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '비밀번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
prefixIcon: const Icon(
Icons.lock_outline,
),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1319,7 +1344,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
minimumSize: const Size.fromHeight(50),
),
child: Text(
tr('ui.userfront.login.action.submit', fallback: '로그인'),
tr(
'ui.userfront.login.action.submit',
fallback: '로그인',
),
),
),
],
@@ -1340,7 +1368,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
hintText: '',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
prefixIcon: const Icon(
Icons.person_outline,
),
),
onSubmitted: (_) => _handleLinkLogin(),
),
@@ -1363,7 +1393,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.link.helper',
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
@@ -1371,9 +1404,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Text(
tr(
'msg.userfront.login.link.short_code_help',
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
fallback:
'링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
@@ -1382,16 +1419,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded(
flex: 2,
child: TextField(
controller: _shortCodePrefixController,
textCapitalization: TextCapitalization.characters,
controller:
_shortCodePrefixController,
textCapitalization:
TextCapitalization.characters,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.short_code.prefix',
fallback: '영문 2자리',
),
border: const OutlineInputBorder(),
border:
const OutlineInputBorder(),
hintText: 'AB',
hintStyle: const TextStyle(color: Colors.grey),
hintStyle: const TextStyle(
color: Colors.grey,
),
),
maxLength: 2,
),
@@ -1400,22 +1442,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded(
flex: 4,
child: TextField(
controller: _shortCodeDigitsController,
controller:
_shortCodeDigitsController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.short_code.digits',
fallback: '숫자 6자리',
),
border: const OutlineInputBorder(),
border:
const OutlineInputBorder(),
hintText: '345678',
hintStyle: const TextStyle(color: Colors.grey),
hintStyle: const TextStyle(
color: Colors.grey,
),
suffixText: _linkExpireSeconds > 0
? tr(
'ui.userfront.login.short_code.expire_time',
fallback: '유효시간 {{time}}',
params: {
'time': _formatTime(_linkExpireSeconds),
'time': _formatTime(
_linkExpireSeconds,
),
},
)
: null,
@@ -1428,13 +1476,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12),
FilledButton(
onPressed: () {
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
final digits = _shortCodeDigitsController.text.trim();
if (prefix.length != 2 || digits.length != 6) {
final prefix =
_shortCodePrefixController.text
.trim()
.toUpperCase();
final digits =
_shortCodeDigitsController.text
.trim();
if (prefix.length != 2 ||
digits.length != 6) {
_showError(
tr(
'msg.userfront.login.short_code.invalid',
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
fallback:
'문자 2개와 숫자 6자리를 입력해 주세요.',
),
);
return;
@@ -1458,27 +1513,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
fallback:
'재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) {
_showError(
tr(
'msg.userfront.login.link.missing_login_id',
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
fallback:
'이메일 또는 휴대폰 번호를 입력해 주세요.',
),
);
return;
}
_startEnchantedFlow(
loginId,
isEmail: _lastLinkIsEmail || loginId.contains('@'),
isEmail:
_lastLinkIsEmail ||
loginId.contains('@'),
codeOnly: false,
);
},
@@ -1488,7 +1551,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.resend_with_time',
fallback: '재발송 ({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
)
: tr(
@@ -1505,15 +1570,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
fallback:
'재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) {
_showError(
tr(
@@ -1534,7 +1604,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.code_only',
fallback: '코드만 받기({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
),
@@ -1553,13 +1625,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const CircularProgressIndicator()
else if (_qrImageBase64 != null)
Column(
crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(
12,
),
),
child: QrImageView(
data: _qrImageBase64!,
@@ -1570,20 +1647,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12),
Text(
_qrRemainingSeconds > 0
? tr(
'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}',
params: {
'time': _formatTime(_qrRemainingSeconds),
},
)
: tr(
'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
? tr(
'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}',
params: {
'time': _formatTime(
_qrRemainingSeconds,
),
},
)
: tr(
'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
textAlign: TextAlign.center,
style: TextStyle(
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
color: _qrRemainingSeconds > 30
? Colors.blue
: Colors.red,
fontWeight: FontWeight.bold,
),
),
@@ -1594,7 +1675,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '모바일 앱으로 스캔하세요',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
TextButton(
onPressed: _startQrFlow,
@@ -1640,7 +1724,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.no_account',
fallback: '계정이 없으신가요?',
),
style: const TextStyle(color: Colors.grey, fontSize: 14),
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
TextButton(
onPressed: () => context.push('/signup'),
@@ -14,23 +14,27 @@ class LoginSuccessScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
const Icon(
Icons.check_circle_outline,
size: 80,
color: Colors.green,
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
tr(
'msg.userfront.login_success.subtitle',
fallback: '성공적으로 로그인되었습니다.',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
const SizedBox(height: 48),
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
FilledButton.icon(
onPressed: () {
@@ -38,12 +42,18 @@ class LoginSuccessScreen extends StatelessWidget {
},
icon: const Icon(Icons.camera_alt, size: 28),
label: Text(
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
tr(
'ui.userfront.login_success.qr',
fallback: 'QR 인증 (카메라 켜기)',
),
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700,
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
@@ -87,7 +87,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
Future<void> _onDetect(BarcodeCapture capture) async {
if (_isScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
if (barcode.rawValue != null) {
@@ -119,7 +119,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
final approveRef = qrData;
final storedToken = AuthTokenStore.getToken();
final sessionToken = storedToken;
var usesCookie = AuthTokenStore.usesCookie();
@@ -140,7 +140,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
token: sessionToken,
withCredentials: usesCookie,
);
if (mounted) {
setState(() {
_isSuccess = true;
@@ -226,7 +226,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
const SizedBox(height: 16),
Text(
title,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 12),
Text(
@@ -268,7 +272,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
controller: controller,
onDetect: _onDetect,
errorBuilder: (context, error) {
final isPermissionDenied = error.errorCode ==
final isPermissionDenied =
error.errorCode ==
MobileScannerErrorCode.permissionDenied;
return Center(
child: Column(
@@ -295,7 +300,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
: _requestCameraPermission,
child: Text(
_isRequestingCamera
? tr('ui.common.requesting', fallback: '요청 중...')
? tr(
'ui.common.requesting',
fallback: '요청 중...',
)
: tr(
'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기',
@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
String? _loginId;
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
// 2. Fallback to URI query parameter if not available via router
if (_loginId == null || _loginId!.isEmpty) {
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
_loadPolicy();
}
@@ -66,7 +67,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
if ((_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)) {
_showError(
tr(
'msg.userfront.reset.invalid_link',
@@ -163,9 +165,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
);
}
if (requiresNumber) {
parts.add(
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
);
parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
}
if (requiresSymbol) {
parts.add(
@@ -180,16 +180,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
),
title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
centerTitle: true,
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
child:
(_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)
? _buildInvalidTokenView()
: Form(
key: _formKey,
@@ -227,7 +227,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
_isPasswordObscured
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
@@ -244,7 +246,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '비밀번호를 입력해주세요.',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minLength =
(_policy?['minLength'] as int?) ?? 12;
if (val.length < minLength) {
return tr(
'msg.userfront.reset.error.min_length',
@@ -262,7 +265,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (hasNumber) typeCount++;
if (hasSymbol) typeCount++;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
final minTypes =
(_policy?['minCharacterTypes'] as int?) ?? 0;
if (minTypes > 0 && typeCount < minTypes) {
return tr(
'msg.userfront.reset.error.min_types',
@@ -290,7 +294,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
);
}
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
if ((_policy?['nonAlphanumeric'] ?? true) &&
!hasSymbol) {
return tr(
'msg.userfront.reset.error.symbol',
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
@@ -312,11 +317,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
_isConfirmPasswordObscured
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
_isConfirmPasswordObscured =
!_isConfirmPasswordObscured;
});
},
),
@@ -369,8 +377,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16),
Text(
tr('msg.userfront.reset.invalid_title',
fallback: '유효하지 않은 링크입니다.'),
tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
),
);
return;
}
setState(() { _isLoading = true; _emailError = null; });
setState(() {
_isLoading = true;
_emailError = null;
});
try {
final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
),
);
return;
}
await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email');
} catch (e) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
} finally {
setState(() => _isLoading = false);
}
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _emailCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
final success = await AuthProxyService.verifySignupCode(
_emailController.text.trim(),
'email',
code,
);
if (success) {
setState(() {
_isEmailVerified = true;
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
_emailError = null;
});
} else {
setState(() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
} catch (e) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
}
}
Future<void> _sendPhoneCode() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
setState(() { _isLoading = true; _phoneError = null; });
setState(() {
_isLoading = true;
_phoneError = null;
});
try {
await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone');
} catch (e) {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
} finally {
setState(() => _isLoading = false);
}
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _phoneCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
final success = await AuthProxyService.verifySignupCode(
_phoneController.text.trim(),
'phone',
code,
);
if (success) {
setState(() {
_isPhoneVerified = true;
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneError = null;
});
} else {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
} catch (e) {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
}
}
Future<void> _handleSignup() async {
if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
));
setState(
() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
),
);
return;
}
if (!_formKey.currentState!.validate()) return;
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
phone: _phoneController.text.trim(),
affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
department: _deptController.text.trim().isEmpty
? (_affiliationType == 'GENERAL' ? 'External' : '')
: _deptController.text.trim(),
termsAccepted: true,
);
if (mounted) _showSuccessDialog();
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
children: [
CircleAvatar(
radius: 12,
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
backgroundColor: isDone
? Colors.green
: (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone
? const Icon(Icons.check, size: 14, color: Colors.white)
: Text(
'$step',
style: TextStyle(
color: isCurrent ? Colors.white : Colors.black54,
fontSize: 10,
),
),
),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)),
Text(
label,
style: TextStyle(
fontSize: 9,
color: isCurrent ? Colors.black : Colors.grey,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
),
),
],
);
}
@@ -438,10 +489,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: CheckboxListTile(
title: Text(
tr(
'ui.userfront.signup.agreement.all',
fallback: '모두 동의합니다',
),
tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
value: _termsAccepted && _privacyAccepted,
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
return Column(
children: [
CheckboxListTile(
title: Text(title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
title: Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
value: value,
onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading,
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
child: SingleChildScrollView(
child: Text(
content,
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
height: 1.5,
),
),
),
),
@@ -517,8 +571,8 @@ class _SignupScreenState extends State<SignupScreen> {
}
static String get _tosText => tr(
'msg.userfront.signup.tos_full',
fallback: """
'msg.userfront.signup.tos_full',
fallback: """
바론 소프트웨어 이용약관
제1장 총칙
@@ -589,11 +643,11 @@ class _SignupScreenState extends State<SignupScreen> {
부칙
본 약관은 2024년 10월 1일부터 시행됩니다.
""",
);
);
static String get _privacyText => tr(
'msg.userfront.signup.privacy_full',
fallback: """
'msg.userfront.signup.privacy_full',
fallback: """
개인정보 수집 및 이용 동의
바론서비스 개인정보처리방침
@@ -702,7 +756,7 @@ class _SignupScreenState extends State<SignupScreen> {
제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
""",
);
);
Widget _buildStepAuth() {
return Column(
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 가족사 이메일 안내 문구
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
@@ -730,7 +787,11 @@ class _SignupScreenState extends State<SignupScreen> {
'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
),
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
),
],
@@ -753,7 +814,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.auth.email.label',
fallback: '이메일 주소',
),
border: const OutlineInputBorder(),
border: const OutlineInputBorder(),
errorText: _emailError,
hintText: 'example@hanmaceng.co.kr',
),
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
onPressed: (_isEmailVerified || _isLoading)
? null
: _sendEmailCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text(
_emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyEmailCode();
},
),
],
if (_isEmailVerified)
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
onPressed: (_isPhoneVerified || _isLoading)
? null
: _sendPhoneCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text(
_phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyPhoneCode();
},
),
],
if (_isPhoneVerified)
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _nameController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.name',
fallback: '이름',
),
labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
border: const OutlineInputBorder(),
),
),
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
DropdownMenuItem(
value: 'GENERAL',
child: Text(
tr(
'domain.affiliation.general',
fallback: '일반 사용자',
),
tr('domain.affiliation.general', fallback: '일반 사용자'),
),
),
DropdownMenuItem(
value: 'AFFILIATE',
child: Text(
tr(
'domain.affiliation.affiliate',
fallback: '가족사 임직원',
),
tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
),
),
],
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
items: [
DropdownMenuItem(
value: 'HANMAC',
child: Text(
tr('domain.company.hanmac', fallback: '한맥'),
),
child: Text(tr('domain.company.hanmac', fallback: '한맥')),
),
DropdownMenuItem(
value: 'SAMAN',
child: Text(
tr('domain.company.saman', fallback: '삼안'),
),
child: Text(tr('domain.company.saman', fallback: '삼안')),
),
DropdownMenuItem(
value: 'PTC',
child: Text(
tr('domain.company.ptc', fallback: 'PTC'),
),
child: Text(tr('domain.company.ptc', fallback: 'PTC')),
),
DropdownMenuItem(
value: 'JANGHEON',
child: Text(
tr('domain.company.jangheon', fallback: '장헌'),
),
child: Text(tr('domain.company.jangheon', fallback: '장헌')),
),
DropdownMenuItem(
value: 'BARON',
child: Text(
tr('domain.company.baron', fallback: '바론'),
),
child: Text(tr('domain.company.baron', fallback: '바론')),
),
DropdownMenuItem(
value: 'HALLA',
child: Text(
tr('domain.company.halla', fallback: '한라'),
),
child: Text(tr('domain.company.halla', fallback: '한라')),
),
],
onChanged: _isAffiliateEmail
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)',
),
border: const OutlineInputBorder()
border: const OutlineInputBorder(),
),
),
],
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
);
}
if (requiresUpper) {
parts.add(
tr(
'msg.userfront.signup.policy.uppercase',
fallback: '대문자',
),
);
parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
}
if (requiresLower) {
parts.add(
tr(
'msg.userfront.signup.policy.lowercase',
fallback: '소문자',
),
);
parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
}
if (requiresNumber) {
parts.add(
tr(
'msg.userfront.signup.policy.number',
fallback: '숫자',
),
);
parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
}
if (requiresSymbol) {
parts.add(
tr(
'msg.userfront.signup.policy.symbol',
fallback: '특수문자',
),
);
parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
}
return tr(
@@ -1117,7 +1159,7 @@ class _SignupScreenState extends State<SignupScreen> {
Widget _buildStepPassword() {
String p = _passwordController.text;
// Default Policy Fallback
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 비밀번호 정책 안내 박스
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.security, size: 18, color: Colors.blue),
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded(
child: Text(
_buildPolicyDescription(),
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
style: TextStyle(
fontSize: 12,
color: Colors.blue[800],
fontWeight: FontWeight.w500,
),
),
),
],
@@ -1219,10 +1268,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
if (requiresNumber)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.number',
fallback: '숫자',
),
tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
hasDigit,
),
if (requiresSymbol)
@@ -1253,7 +1299,7 @@ class _SignupScreenState extends State<SignupScreen> {
labelText: tr(
'ui.userfront.signup.password.confirm_label',
fallback: '비밀번호 확인',
),
),
border: const OutlineInputBorder(),
errorText: _confirmPasswordError,
),
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
Icon(
isValid ? Icons.check_circle : Icons.circle_outlined,
size: 14,
color: isValid ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)),
Text(
label,
style: TextStyle(
fontSize: 11,
color: isValid ? Colors.green : Colors.grey,
),
),
],
);
}
@@ -1276,8 +1332,10 @@ class _SignupScreenState extends State<SignupScreen> {
@override
Widget build(BuildContext context) {
bool canGoNext = false;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
canGoNext = true;
if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty;
if (_affiliationType == 'GENERAL') {
@@ -1313,11 +1371,13 @@ class _SignupScreenState extends State<SignupScreen> {
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: _currentStep == 1
? _buildStepAgreement()
: (_currentStep == 2
? _buildStepAuth()
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
child: _currentStep == 1
? _buildStepAgreement()
: (_currentStep == 2
? _buildStepAuth()
: (_currentStep == 3
? _buildStepInfo()
: _buildStepPassword())),
),
),
),
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(55),
side: const BorderSide(color: Colors.black),
),
child: Text(
tr('ui.common.prev', fallback: '이전'),
style: const TextStyle(color: Colors.black),
@@ -1340,20 +1403,35 @@ class _SignupScreenState extends State<SignupScreen> {
],
Expanded(
child: FilledButton(
onPressed: _currentStep < 4
? (canGoNext ? () => setState(() => _currentStep++) : null)
: (_isLoading ? null : _handleSignup),
onPressed: _currentStep < 4
? (canGoNext
? () => setState(() => _currentStep++)
: null)
: (_isLoading ? null : _handleSignup),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55),
backgroundColor: Colors.black,
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(
_currentStep < 4
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
_currentStep < 4
? tr(
'ui.userfront.signup.next_step',
fallback: '다음 단계',
)
: tr(
'ui.userfront.signup.complete',
fallback: '가입 완료',
),
),
),
),
],
@@ -18,22 +18,19 @@ String _envOrDefault(String key, String fallback) {
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
final queryParameters = <String, String>{
'limit': '20',
};
final queryParameters = <String, String>{'limit': '20'};
if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor;
}
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline')
.replace(queryParameters: queryParameters);
final url = Uri.parse(
'$_baseUrl/api/v1/audit/auth/timeline',
).replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -60,10 +57,6 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
}
}
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
@@ -86,11 +79,11 @@ class AuthTimelineState {
});
const AuthTimelineState.initial()
: items = const [],
nextCursor = null,
isLoading = false,
isLoadingMore = false,
error = null;
: items = const [],
nextCursor = null,
isLoading = false,
isLoadingMore = false,
error = null;
AuthTimelineState copyWith({
List<AuditLogEntry>? items,
@@ -188,6 +181,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
}
}
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
AuthTimelineNotifier.new,
);
final authTimelineProvider =
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
AuthTimelineNotifier.new,
);
@@ -64,14 +64,12 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
try {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -85,7 +83,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
return items
.whereType<Map<String, dynamic>>()
.map(LinkedRp.fromJson)
@@ -106,6 +104,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
}
}
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
return LinkedRpsNotifier();
});
final linkedRpsProvider =
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
return LinkedRpsNotifier();
});
@@ -68,8 +68,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.revoke.confirm',
fallback:
'{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
fallback: '{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
params: {'app': appName},
),
),
@@ -81,8 +80,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child:
Text(tr('ui.userfront.dashboard.revoke.confirm_button', fallback: '해지하기')),
child: Text(
tr(
'ui.userfront.dashboard.revoke.confirm_button',
fallback: '해지하기',
),
),
),
],
),
@@ -158,31 +161,45 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('ui.userfront.dashboard.scopes.title',
fallback: '권한 (Scopes)'),
tr(
'ui.userfront.dashboard.scopes.title',
fallback: '권한 (Scopes)',
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (item.scopes.isEmpty)
Text(
tr('msg.userfront.dashboard.scopes.empty',
fallback: '요청된 권한이 없습니다.'),
tr(
'msg.userfront.dashboard.scopes.empty',
fallback: '요청된 권한이 없습니다.',
),
style: const TextStyle(color: Colors.grey),
)
else
Wrap(
spacing: 8,
runSpacing: 4,
children: item.scopes.map((s) => Chip(
label: Text(s, style: const TextStyle(fontSize: 12)),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)).toList(),
children: item.scopes
.map(
(s) => Chip(
label: Text(
s,
style: const TextStyle(fontSize: 12),
),
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
)
.toList(),
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.dashboard.status_history',
fallback: '상태 이력'),
tr(
'ui.userfront.dashboard.status_history',
fallback: '상태 이력',
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -200,10 +217,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Builder(
builder: (context) {
final statusLabel = item.status == 'active'
? tr('ui.common.status.active',
fallback: '활성')
: tr('ui.userfront.dashboard.status.revoked',
fallback: '해지됨');
? tr('ui.common.status.active', fallback: '활성')
: tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
);
return Text(
tr(
'msg.userfront.dashboard.current_status',
@@ -242,8 +260,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title:
Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
selected: true,
onTap: () {
if (closeOnTap) {
@@ -254,8 +271,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
ListTile(
leading: const Icon(Icons.person_outline),
title:
Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
@@ -265,8 +281,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title:
Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
@@ -277,8 +292,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title:
Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
onTap: () async {
if (closeOnTap) {
Navigator.of(context).pop();
@@ -297,12 +311,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_revokedClientIds.clear();
});
ref.invalidate(linkedRpsProvider);
await Future.wait([
ref.read(linkedRpsProvider.future),
ref.read(authTimelineProvider.notifier).refresh(),
]);
await _loadAuditLogs(reset: true);
await ref.read(linkedRpsProvider.notifier).refresh();
}
@@ -316,21 +330,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final queryParameters = <String, String>{
'limit': '20',
};
final queryParameters = <String, String>{'limit': '20'};
if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor;
}
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline')
.replace(queryParameters: queryParameters);
final url = Uri.parse(
'$baseUrl/api/v1/audit/auth/timeline',
).replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -401,11 +412,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (parts.length != 3) {
return null;
}
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload) as Map<String, dynamic>;
final iatValue = data['iat'] ?? data['auth_time'];
if (iatValue is num) {
return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal();
return DateTime.fromMillisecondsSinceEpoch(
iatValue.toInt() * 1000,
).toLocal();
}
} catch (_) {
return null;
@@ -467,9 +482,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) {
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedUserAgent =
log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
final hasApproverMeta =
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText(authMethod);
}
@@ -497,7 +514,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
);
}
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false)
final approvedSessionId =
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
false)
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc
@@ -544,8 +563,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
style: TextStyle(
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
decoration:
approvedSessionId.isEmpty ? null : TextDecoration.underline,
decoration: approvedSessionId.isEmpty
? null
: TextDecoration.underline,
),
),
),
@@ -555,9 +575,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) {
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedUserAgent =
log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
final hasApproverMeta =
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText(
tr(
@@ -595,7 +617,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
);
}
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false)
final approvedSessionId =
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
false)
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc
@@ -642,7 +666,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'msg.userfront.dashboard.auth_method',
fallback: '인증수단: {{method}}',
params: {
'method': isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
'method': isOidc
? authMethod
: tr('ui.common.qr', fallback: 'QR'),
},
),
style: TextStyle(
@@ -714,7 +740,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final profileState = ref.watch(profileProvider);
final profile = profileState.value;
final timelineState = ref.watch(authTimelineProvider);
final userName = profile?.name ??
final userName =
profile?.name ??
profile?.email ??
profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User');
@@ -751,7 +778,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
],
),
drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
drawer: isWide
? null
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row(
children: [
if (isWide)
@@ -775,11 +804,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMobile) ...[
_buildHeaderCard(userName, department, sessionIssuedAt),
_buildHeaderCard(
userName,
department,
sessionIssuedAt,
),
const SizedBox(height: 28),
],
_buildSectionTitle(
tr('ui.userfront.sections.apps', fallback: '나의 App 현황'),
tr(
'ui.userfront.sections.apps',
fallback: '나의 App 현황',
),
tr(
'msg.userfront.sections.apps_subtitle',
fallback: '현재 연결된 앱과 최근 인증 상태입니다.',
@@ -810,7 +846,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) {
Widget _buildHeaderCard(
String userName,
String department,
DateTime? issuedAt,
) {
final sessionLabel = issuedAt != null
? _formatDateTime(issuedAt)
: tr('ui.userfront.session.unknown', fallback: '알 수 없음');
@@ -823,7 +863,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
fallback: '안녕하세요, {{name}}님',
params: {'name': userName},
),
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: _ink),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _ink,
),
),
const SizedBox(height: 6),
Text(
@@ -871,13 +915,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
),
const SizedBox(width: 12),
Text(
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
],
);
}
@@ -897,7 +942,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
style: const TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
),
),
],
),
@@ -920,7 +969,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'msg.userfront.dashboard.activities.empty',
fallback: '연동된 앱이 없습니다.',
),
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
@@ -959,14 +1012,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
final items = <_ActivityItem>[];
for (final rp in linkedRps) {
final normalizedStatus = rp.status.toLowerCase();
// status가 'inactive'로 내려올 수 있으므로 이를 반영
final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == '';
final isActiveInApi =
normalizedStatus == 'active' || normalizedStatus == '';
final isRevoked = !isActiveInApi;
final lastAuthLabel = rp.lastAuthenticatedAt != null
@@ -975,7 +1027,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final statusCode = isRevoked ? 'revoked' : 'active';
final name = rp.name.isNotEmpty ? rp.name : rp.id;
items.add(
_ActivityItem(
clientId: rp.id,
@@ -995,17 +1047,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
items.sort((a, b) {
final aActive = a.status == 'active';
final bActive = b.status == 'active';
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) {
return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!);
}
if (a.lastAuthDateTime != null) return -1;
if (b.lastAuthDateTime != null) return 1;
return 0;
});
@@ -1018,7 +1070,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
// 화면 너비에 따른 컬럼 수 및 초기 표시 개수 결정
int crossAxisCount;
if (maxWidth > 1200) {
@@ -1042,7 +1094,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
final double spacing = 12.0;
final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
final double cardWidth =
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1063,18 +1116,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => setState(() => _showAllActivities = !_showAllActivities),
onPressed: () => setState(
() => _showAllActivities = !_showAllActivities,
),
icon: Icon(
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
size: 18,
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
color: _showAllActivities
? Colors.grey
: Colors.blueAccent,
),
label: Text(
_showAllActivities
? tr('ui.common.collapse', fallback: '접기')
: tr('ui.common.show_more', fallback: '+ 더보기'),
style: TextStyle(
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
color: _showAllActivities
? Colors.grey
: Colors.blueAccent,
fontWeight: FontWeight.bold,
),
),
@@ -1090,7 +1149,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
final isActive = item.status == 'active';
final statusColor = isActive ? Colors.green : Colors.grey;
final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border;
final borderColor = isActive
? Colors.green.withValues(alpha: 128)
: _border;
final borderWidth = isActive ? 1.5 : 1.0;
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
@@ -1104,13 +1165,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
color: _surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor, width: borderWidth),
boxShadow: isActive ? [
BoxShadow(
color: Colors.green.withValues(alpha: 13),
blurRadius: 10,
offset: const Offset(0, 4),
)
] : null,
boxShadow: isActive
? [
BoxShadow(
color: Colors.green.withValues(alpha: 13),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1120,7 +1183,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded(
child: Text(
item.appName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
Container(
@@ -1132,8 +1199,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Text(
item.status == 'active'
? tr('ui.common.status.active', fallback: '활성')
: tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨'),
style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600),
: tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
),
style: TextStyle(
fontSize: 11,
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
],
@@ -1146,7 +1220,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 4),
Text(
item.lastAuthAt,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 16),
Row(
@@ -1168,22 +1246,38 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke,
onPressed: (_isRevoking || item.isRevoked)
? null
: item.onRevoke,
style: OutlinedButton.styleFrom(
foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent,
side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5),
foregroundColor: item.isRevoked
? Colors.grey
: Colors.redAccent,
side: BorderSide(
color: item.isRevoked ? Colors.grey : Colors.redAccent,
width: 0.5,
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: _isRevoking && !item.isRevoked
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.redAccent,
),
)
: Text(
item.isRevoked
? tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨')
: tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'),
? tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
)
: tr(
'ui.userfront.dashboard.revoke.title',
fallback: '연동 해지',
),
style: const TextStyle(fontSize: 13),
),
),
@@ -1268,7 +1362,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const SizedBox(height: 8),
TextButton(
onPressed: () => ref.read(authTimelineProvider.notifier).refresh(),
onPressed: () =>
ref.read(authTimelineProvider.notifier).refresh(),
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
),
],
@@ -1326,7 +1421,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
columns: [
DataColumn(
label: Text(
tr('ui.userfront.audit.table.session_id', fallback: 'Session ID'),
tr(
'ui.userfront.audit.table.session_id',
fallback: 'Session ID',
),
),
),
DataColumn(
@@ -1336,7 +1434,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.app', fallback: '애플리케이션'),
tr(
'ui.userfront.audit.table.app',
fallback: '애플리케이션',
),
),
),
DataColumn(
@@ -1346,17 +1447,26 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.device', fallback: '접속환경'),
tr(
'ui.userfront.audit.table.device',
fallback: '접속환경',
),
),
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.auth_method', fallback: '인증수단'),
tr(
'ui.userfront.audit.table.auth_method',
fallback: '인증수단',
),
),
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.result', fallback: '인증결과'),
tr(
'ui.userfront.audit.table.result',
fallback: '인증결과',
),
),
),
DataColumn(
@@ -1369,47 +1479,57 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final statusLabel = log.status == 'success'
? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패');
final statusColor =
log.status == 'success' ? Colors.green : Colors.redAccent;
final statusColor = log.status == 'success'
? Colors.green
: Colors.redAccent;
final authMethod = log.authMethod.isNotEmpty
? log.authMethod
: _authMethodLabel();
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
return DataRow(cells: [
DataCell(
_selectableText(
log.sessionId.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.sessionId,
),
),
DataCell(_selectableText(_formatDateTime(log.timestamp))),
DataCell(_buildAppCell(log)),
DataCell(
_selectableText(
log.ipAddress.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.ipAddress,
),
),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(
_selectableText(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
final deviceLabel = _deviceLabelFromUserAgent(
log.userAgent,
);
return DataRow(
cells: [
DataCell(
_selectableText(
log.sessionId.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.sessionId,
),
),
),
DataCell(
_selectableText(
tr('ui.userfront.audit.table.pending', fallback: '(준비중)'),
style: const TextStyle(color: Colors.grey),
DataCell(
_selectableText(_formatDateTime(log.timestamp)),
),
),
]);
DataCell(_buildAppCell(log)),
DataCell(
_selectableText(
log.ipAddress.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.ipAddress,
),
),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(
_selectableText(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
DataCell(
_selectableText(
tr(
'ui.userfront.audit.table.pending',
fallback: '(준비중)',
),
style: const TextStyle(color: Colors.grey),
),
),
],
);
}).toList(),
),
),
@@ -1443,7 +1563,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded(
child: _buildAppCell(
log,
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
style: const TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
_selectableText(
@@ -1451,7 +1574,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패'),
style: TextStyle(
color: log.status == 'success' ? Colors.green : Colors.redAccent,
color: log.status == 'success'
? Colors.green
: Colors.redAccent,
fontWeight: FontWeight.w600,
),
),
@@ -1491,10 +1616,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
tr(
'msg.userfront.audit.device',
fallback: '접속환경: {{value}}',
params: {'value': _deviceLabelFromUserAgent(log.userAgent)},
params: {
'value': _deviceLabelFromUserAgent(log.userAgent),
},
),
),
_buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()),
_buildAuthMethodLine(
log,
log.authMethod.isNotEmpty
? log.authMethod
: _authMethodLabel(),
),
_selectableText(
tr(
'msg.userfront.audit.result',
@@ -1507,10 +1639,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
_selectableText(
tr(
'msg.userfront.audit.status',
fallback: '현황: (준비중)',
),
tr('msg.userfront.audit.status', fallback: '현황: (준비중)'),
style: TextStyle(color: Colors.grey[600]),
),
],
@@ -1542,7 +1671,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
TextButton(
onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(),
onPressed: () =>
ref.read(authTimelineProvider.notifier).loadMore(),
child: Text(tr('ui.common.retry', fallback: '재시도')),
),
],
@@ -21,12 +21,7 @@ class Tenant {
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'slug': slug,
'description': description,
};
return {'id': id, 'name': name, 'slug': slug, 'description': description};
}
}
@@ -62,7 +57,9 @@ class UserProfile {
department: json['department'] ?? '',
affiliationType: json['affiliationType'] ?? '',
companyCode: json['companyCode'] ?? '',
metadata: json['metadata'] != null ? Map<String, dynamic>.from(json['metadata']) : null,
metadata: json['metadata'] != null
? Map<String, dynamic>.from(json['metadata'])
: null,
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
);
}
@@ -81,11 +78,7 @@ class UserProfile {
};
}
UserProfile copyWith({
String? name,
String? phone,
String? department,
}) {
UserProfile copyWith({String? name, String? phone, String? department}) {
return UserProfile(
id: id,
email: email,
@@ -13,7 +13,8 @@ class ProfileRepository {
return dotenv.env[key] ?? fallback;
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static String get _baseUrl =>
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
// Helper to get session token
static Future<String?> _getToken() async {
@@ -31,9 +32,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -68,9 +67,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -107,9 +104,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -145,9 +140,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -183,9 +176,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -32,20 +32,20 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
}) async {
// Show loading state
state = const AsyncValue.loading();
// Perform update and then re-fetch profile
state = await AsyncValue.guard(() async {
await ref.read(profileRepositoryProvider).updateMyProfile(
name: name,
phone: phone,
department: department,
);
await ref
.read(profileRepositoryProvider)
.updateMyProfile(name: name, phone: phone, department: department);
return _fetch();
});
}
}
// 3. Provider definition
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(() {
return ProfileNotifier();
});
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
() {
return ProfileNotifier();
},
);
@@ -140,7 +140,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField != 'name' && _nameController!.text != profile.name) {
_nameController!.text = profile.name;
}
if (_editingField != 'department' && _departmentController!.text != profile.department) {
if (_editingField != 'department' &&
_departmentController!.text != profile.department) {
_departmentController!.text = profile.department;
}
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
@@ -274,10 +275,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.verified',
fallback: '인증되었습니다.',
),
tr('msg.userfront.profile.phone.verified', fallback: '인증되었습니다.'),
),
),
);
@@ -310,24 +308,30 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
if (currentPassword.isEmpty) {
setState(() => _passwordError = tr(
'msg.userfront.profile.password.current_required',
fallback: '현재 비밀번호를 입력해 주세요.',
));
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.current_required',
fallback: '현재 비밀번호를 입력해 주세요.',
),
);
return;
}
if (newPassword.isEmpty) {
setState(() => _passwordError = tr(
'msg.userfront.profile.password.new_required',
fallback: '새 비밀번호를 입력해 주세요.',
));
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.new_required',
fallback: '새 비밀번호를 입력해 주세요.',
),
);
return;
}
if (newPassword != confirmPassword) {
setState(() => _passwordError = tr(
'msg.userfront.profile.password.mismatch',
fallback: '새 비밀번호가 일치하지 않습니다.',
));
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.mismatch',
fallback: '새 비밀번호가 일치하지 않습니다.',
),
);
return;
}
@@ -338,7 +342,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
});
try {
await ref.read(profileRepositoryProvider).changePassword(
await ref
.read(profileRepositoryProvider)
.changePassword(
currentPassword: currentPassword,
newPassword: newPassword,
);
@@ -434,10 +440,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.name_required',
fallback: '이름을 입력해주세요.',
),
tr('msg.userfront.profile.name_required', fallback: '이름을 입력해주세요.'),
),
),
);
@@ -500,7 +503,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isSavingField = true;
try {
await ref.read(profileProvider.notifier).updateProfile(
await ref
.read(profileProvider.notifier)
.updateProfile(
name: nextName,
phone: nextPhone,
department: nextDepartment,
@@ -551,32 +556,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(
tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
),
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
onTap: () => context.go('/'),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(
tr('ui.userfront.nav.profile', fallback: '내 정보'),
),
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
selected: true,
onTap: () => context.go('/profile'),
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: Text(
tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
),
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
onTap: () => context.go('/scan'),
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: Text(
tr('ui.userfront.nav.logout', fallback: '로그아웃'),
),
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
onTap: _logout,
),
],
@@ -589,13 +586,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
),
const SizedBox(width: 12),
Text(
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
],
);
}
@@ -615,7 +613,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
style: const TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
),
),
],
),
@@ -650,10 +652,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
child: Row(
children: [
const CircleAvatar(
radius: 32,
child: Icon(Icons.person, size: 32),
),
const CircleAvatar(radius: 32, child: Icon(Icons.person, size: 32)),
const SizedBox(width: 16),
Expanded(
child: Column(
@@ -672,7 +671,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
),
const SizedBox(height: 6),
Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
Text(
email,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
@@ -682,7 +684,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Icons.badge_outlined,
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
),
_buildInfoChip(Icons.apartment, profile.tenant?.name ?? department),
_buildInfoChip(
Icons.apartment,
profile.tenant?.name ?? department,
),
],
),
],
@@ -787,9 +792,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (!isEditing) {
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
),
title: Text(tr('ui.userfront.profile.phone.title', fallback: '전화번호')),
subtitle: Text(displayValue),
trailing: TextButton(
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
@@ -918,7 +921,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility),
icon: Icon(
_showCurrentPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(() {
_showCurrentPassword = !_showCurrentPassword;
}),
@@ -936,7 +943,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility),
icon: Icon(
_showNewPassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() {
_showNewPassword = !_showNewPassword;
}),
@@ -954,7 +963,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility),
icon: Icon(
_showConfirmPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(() {
_showConfirmPassword = !_showConfirmPassword;
}),
@@ -963,10 +976,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
if (_passwordError != null) ...[
const SizedBox(height: 12),
Text(
_passwordError!,
style: const TextStyle(color: Colors.red),
),
Text(_passwordError!, style: const TextStyle(color: Colors.red)),
],
if (_passwordSuccess != null) ...[
const SizedBox(height: 12),
@@ -1037,7 +1047,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
_buildEditableTile(
field: 'name',
label: tr('ui.userfront.profile.field.name', fallback: '이름'),
label: tr(
'ui.userfront.profile.field.name',
fallback: '이름',
),
value: profile.name,
profile: profile,
isUpdating: isUpdating,
@@ -1045,7 +1058,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const Divider(height: 24),
_buildReadOnlyTile(
tr('ui.userfront.profile.field.email', fallback: '이메일'),
tr(
'ui.userfront.profile.field.email',
fallback: '이메일',
),
profile.email,
),
const Divider(height: 24),
@@ -1055,7 +1071,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.profile.section.organization', fallback: '조직 정보'),
tr(
'ui.userfront.profile.section.organization',
fallback: '조직 정보',
),
tr(
'msg.userfront.profile.section.organization',
fallback: '소속 및 구분 정보입니다.',
@@ -1067,7 +1086,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
_buildEditableTile(
field: 'department',
label: tr('ui.userfront.profile.field.department', fallback: '소속'),
label: tr(
'ui.userfront.profile.field.department',
fallback: '소속',
),
value: profile.department,
profile: profile,
isUpdating: isUpdating,
@@ -1075,7 +1097,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const Divider(height: 24),
_buildReadOnlyTile(
tr('ui.userfront.profile.field.affiliation', fallback: '구분'),
tr(
'ui.userfront.profile.field.affiliation',
fallback: '구분',
),
profile.affiliationType,
),
if (profile.tenant != null) ...[
@@ -1091,7 +1116,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (profile.companyCode.isNotEmpty) ...[
const Divider(height: 24),
_buildReadOnlyTile(
tr('ui.userfront.profile.field.company_code', fallback: '회사코드'),
tr(
'ui.userfront.profile.field.company_code',
fallback: '회사코드',
),
profile.companyCode,
),
],
@@ -1148,7 +1176,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
onPressed: () =>
ref.read(profileProvider.notifier).loadProfile(),
child: Text(tr('ui.common.retry', fallback: '재시도')),
),
],
@@ -1193,11 +1222,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
body: Row(
children: [
if (isWide)
SizedBox(
width: 240,
child: _buildSideMenu(context),
),
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
Expanded(child: _buildContent(profile, isUpdating)),
],
),
@@ -4,11 +4,7 @@ class ProfileInfoRow extends StatelessWidget {
final String label;
final String value;
const ProfileInfoRow({
super.key,
required this.label,
required this.value,
});
const ProfileInfoRow({super.key, required this.label, required this.value});
@override
Widget build(BuildContext context) {
+1 -5
View File
@@ -25,11 +25,7 @@ String _formatTemplate(String template, Map<String, String>? params) {
return result;
}
String tr(
String key, {
String? fallback,
Map<String, String>? params,
}) {
String tr(String key, {String? fallback, Map<String, String>? params}) {
final locale = _resolveLocale();
final map = locale == 'en' ? enStrings : koStrings;
final value = map[key];
+126 -63
View File
@@ -29,7 +29,8 @@ const Map<String, String> koStrings = {
"msg.admin.api_keys.create.scopes_count": "총 {{count}}개의 권한이 할당됩니다.",
"msg.admin.api_keys.create.scopes_hint": "생성 즉시 활성화되어 사용 가능합니다.",
"msg.admin.api_keys.create.subtitle": "내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
"msg.admin.api_keys.create.success.copy_hint": "복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
"msg.admin.api_keys.create.success.copy_hint":
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
"msg.admin.api_keys.create.success.notice": "아래의 비밀번호(Secret)는 보안을 위해 ",
"msg.admin.api_keys.create.success.notice_emphasis": "지금 한 번만",
"msg.admin.api_keys.create.success.notice_suffix": "표시됩니다.",
@@ -37,14 +38,16 @@ const Map<String, String> koStrings = {
"msg.admin.api_keys.list.empty": "등록된 API 키가 없습니다.",
"msg.admin.api_keys.list.fetch_error": "API 키 목록 조회에 실패했습니다.",
"msg.admin.api_keys.list.registry.count": "총 {{count}}개 API 키",
"msg.admin.api_keys.list.subtitle": "서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
"msg.admin.api_keys.list.subtitle":
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
"msg.admin.audit.empty": "아직 수집된 감사 로그가 없습니다.",
"msg.admin.audit.end": "End of audit feed",
"msg.admin.audit.filters.empty": "필터 없음",
"msg.admin.audit.load_error": "Error loading logs: {{error}}",
"msg.admin.audit.loading": "Loading audit logs...",
"msg.admin.audit.registry.count": "로드된 로그 {{count}}건",
"msg.admin.audit.subtitle": "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
"msg.admin.audit.subtitle":
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
"msg.admin.groups.list.subtitle": "이 테넌트에 정의된 사용자 그룹 목록입니다.",
"msg.admin.groups.members.count": "{{count}} 명",
"msg.admin.groups.members.empty": "멤버가 없습니다.",
@@ -52,33 +55,43 @@ const Map<String, String> koStrings = {
"msg.admin.groups.prompt.user_id": "추가할 사용자의 UUID를 입력하세요:",
"msg.admin.header.subtitle": "Tenant isolation & least privilege by default",
"msg.admin.idp_env_prod": "IDP env: prod",
"msg.admin.notice.idp_policy": "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.",
"msg.admin.notice.idp_policy":
"IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다.",
"msg.admin.notice.scope": "관리 기능은 /admin 네임스페이스에서만 노출합니다.",
"msg.admin.overview.description": "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다.",
"msg.admin.overview.idp_fallback": "Fallback: Descope",
"msg.admin.overview.idp_primary": "IDP: Ory primary",
"msg.admin.overview.playbook.description": "운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
"msg.admin.overview.playbook.idp_body": "모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
"msg.admin.overview.playbook.description":
"운영 정책, 레이트리밋, 감사 로그의 기본 룰을 요약합니다.",
"msg.admin.overview.playbook.idp_body":
"모든 IDP 호출은 backend를 통해서만 수행하며, Hydra/Kratos admin 포트는 외부에 노출하지 않습니다.",
"msg.admin.overview.playbook.idp_title": "Backend-only IDP access",
"msg.admin.overview.playbook.tenant_body": "Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
"msg.admin.overview.playbook.tenant_body":
"Tenant 헤더와 감사 로그 규칙을 기본 적용하며, 향후 Keto 정책으로 확장 예정입니다.",
"msg.admin.overview.playbook.tenant_title": "Tenant isolation",
"msg.admin.overview.quick_links.description": "주요 운영 화면으로 바로 이동합니다.",
"msg.admin.scope_admin": "Scoped to /admin",
"msg.admin.session_ttl": "Session TTL: 15m admin",
"msg.admin.tenant_headers": "Tenant-aware headers",
"msg.admin.tenants.create.form.domains_help": "Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.memo.body": "생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
"msg.admin.tenants.create.memo.subtitle": "Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
"msg.admin.tenants.create.profile.subtitle": "필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
"msg.admin.tenants.create.form.domains_help":
"Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.memo.body":
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
"msg.admin.tenants.create.memo.subtitle":
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
"msg.admin.tenants.create.profile.subtitle":
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
"msg.admin.tenants.create.subtitle": "글로벌 운영 기준의 신규 테넌트를 등록합니다.",
"msg.admin.tenants.delete_confirm": "테넌트 \\\"{{name}}\\\"를 삭제할까요?",
"msg.admin.tenants.empty": "아직 등록된 테넌트가 없습니다.",
"msg.admin.tenants.fetch_error": "테넌트 목록 조회에 실패했습니다.",
"msg.admin.tenants.members.empty": "소속된 사용자가 없습니다.",
"msg.admin.tenants.registry.count": "총 {{count}}개 테넌트",
"msg.admin.tenants.schema.empty": "No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.empty":
"No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.missing_id": "Tenant ID missing",
"msg.admin.tenants.schema.subtitle": "Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.subtitle":
"Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.update_error": "Failed to update schema",
"msg.admin.tenants.schema.update_success": "Schema updated successfully",
"msg.admin.tenants.sub.empty": "하위 테넌트가 없습니다.",
@@ -88,19 +101,23 @@ const Map<String, String> koStrings = {
"msg.admin.users.create.error": "사용자 생성에 실패했습니다.",
"msg.admin.users.create.form.email_required": "이메일은 필수입니다.",
"msg.admin.users.create.form.name_required": "이름은 필수입니다.",
"msg.admin.users.create.form.password_auto_help": "비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
"msg.admin.users.create.form.password_auto_help":
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
"msg.admin.users.create.form.password_manual_help": "초기 비밀번호를 직접 설정합니다.",
"msg.admin.users.create.form.role_help": "시스템 접근 권한을 결정합니다.",
"msg.admin.users.create.password_generated.default": "초기 비밀번호가 생성되었습니다.",
"msg.admin.users.create.password_generated.with_email": "{{email}} 계정의 초기 비밀번호입니다.",
"msg.admin.users.create.password_generated.with_email":
"{{email}} 계정의 초기 비밀번호입니다.",
"msg.admin.users.create.password_required": "비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
"msg.admin.users.detail.edit_subtitle": "{{email}} 계정의 정보를 수정합니다.",
"msg.admin.users.detail.form.name_required": "이름은 필수입니다.",
"msg.admin.users.detail.not_found": "사용자를 찾을 수 없습니다.",
"msg.admin.users.detail.security.password_hint": "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
"msg.admin.users.detail.security.password_hint":
"비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다.",
"msg.admin.users.detail.update_error": "사용자 수정에 실패했습니다.",
"msg.admin.users.detail.update_success": "사용자 정보가 수정되었습니다.",
"msg.admin.users.list.delete_confirm": "사용자 \\\"{{name}}\\\"을(를) 정말 삭제하시겠습니까?",
"msg.admin.users.list.delete_confirm":
"사용자 \\\"{{name}}\\\"을(를) 정말 삭제하시겠습니까?",
"msg.admin.users.list.empty": "검색 결과가 없습니다.",
"msg.admin.users.list.fetch_error": "사용자 목록 조회에 실패했습니다.",
"msg.admin.users.list.registry.count": "총 {{count}}명의 사용자가 등록되어 있습니다.",
@@ -111,7 +128,8 @@ const Map<String, String> koStrings = {
"msg.dev.clients.consents.empty": "No consents found.",
"msg.dev.clients.consents.load_error": "Error loading consents: {{error}}",
"msg.dev.clients.consents.loading": "Loading consents...",
"msg.dev.clients.consents.showing": "Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.showing":
"Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.subtitle": "OIDC Relying Party 사용자 권한을 검토·관리합니다.",
"msg.dev.clients.copy_client_id": "클라이언트 ID가 복사되었습니다.",
"msg.dev.clients.details.copy_client_id": "Client ID가 복사되었습니다.",
@@ -120,39 +138,51 @@ const Map<String, String> koStrings = {
"msg.dev.clients.details.load_error": "Error loading client: {{error}}",
"msg.dev.clients.details.loading": "Loading client...",
"msg.dev.clients.details.missing_id": "Client ID가 필요합니다.",
"msg.dev.clients.details.redirect.description": "인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
"msg.dev.clients.details.redirect.description":
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
"msg.dev.clients.details.redirect_saved": "Redirect URIs가 저장되었습니다.",
"msg.dev.clients.details.rotate_confirm": "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\\\\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
"msg.dev.clients.details.rotate_confirm":
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\\\\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
"msg.dev.clients.details.rotate_error": "재발급 실패: {{error}}",
"msg.dev.clients.details.save_error": "저장 실패: {{error}}",
"msg.dev.clients.details.secret_rotated": "Client Secret이 재발급되었습니다.",
"msg.dev.clients.details.secret_unavailable": "SECRET_NOT_AVAILABLE",
"msg.dev.clients.details.security.footer": "비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
"msg.dev.clients.details.security.note": "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
"msg.dev.clients.details.security.footer":
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
"msg.dev.clients.details.security.note":
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
"msg.dev.clients.details.subtitle": "OIDC 자격 증명과 엔드포인트를 관리합니다.",
"msg.dev.clients.general.identity.logo_help": "인증 화면에 표시될 PNG/SVG URL입니다.",
"msg.dev.clients.general.identity.subtitle": "앱 이름과 설명, 로고를 설정합니다.",
"msg.dev.clients.general.load_error": "Error loading client: {{error}}",
"msg.dev.clients.general.loading": "Loading client...",
"msg.dev.clients.general.redirect.help": "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
"msg.dev.clients.general.redirect.help":
"인증 후 리다이렉트될 URI를 입력하세요. 생성 후 Connection 탭에서 수정 가능합니다.",
"msg.dev.clients.general.saved": "설정이 저장되었습니다.",
"msg.dev.clients.general.scopes.empty": "등록된 스코프가 없습니다.",
"msg.dev.clients.general.scopes.subtitle": "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다.",
"msg.dev.clients.general.security.confidential_help": "서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
"msg.dev.clients.general.security.public_help": "SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
"msg.dev.clients.general.security.subtitle": "클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
"msg.dev.clients.help.docs_body": "Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle": "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.general.security.confidential_help":
"서버 사이드 앱(예: Node.js, Java)처럼 비밀키를 안전하게 보관 가능한 경우.",
"msg.dev.clients.general.security.public_help":
"SPA/모바일 앱처럼 비밀키 보관이 어려운 경우. PKCE를 기본 사용합니다.",
"msg.dev.clients.general.security.subtitle":
"클라이언트 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다.",
"msg.dev.clients.help.docs_body":
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle":
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.load_error": "Error loading clients: {{error}}",
"msg.dev.clients.loading": "Loading clients...",
"msg.dev.clients.registry.description": "OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
"msg.dev.clients.registry.description":
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
"msg.dev.clients.scopes.email": "이메일 주소 접근",
"msg.dev.clients.scopes.openid": "OIDC 인증 필수 스코프",
"msg.dev.clients.scopes.profile": "기본 프로필 정보 접근",
"msg.dev.clients.showing": "Showing {{shown}} of {{total}} clients",
"msg.dev.clients.status_update_error": "Failed to update client status",
"msg.dev.clients.status_updated": "클라이언트가 {{status}}되었습니다.",
"msg.dev.dashboard.hero.body": "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
"msg.dev.dashboard.hero.body":
"Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다.",
"msg.dev.dashboard.hero.title_emphasis": " 하나의 화면",
"msg.dev.dashboard.hero.title_prefix": "RP 등록 현황과 Consent 상태를",
"msg.dev.dashboard.hero.title_suffix": "에서 관리합니다.",
@@ -171,12 +201,15 @@ const Map<String, String> koStrings = {
"msg.userfront.audit.session_id": "Session ID: {{value}}",
"msg.userfront.audit.status": "현황: (준비중)",
"msg.userfront.dashboard.activities.empty": "연동된 앱이 없습니다.",
"msg.userfront.dashboard.activities.empty_detail": "앱을 연동하면 최근 활동과 상태가 표시됩니다.",
"msg.userfront.dashboard.activities.empty_detail":
"앱을 연동하면 최근 활동과 상태가 표시됩니다.",
"msg.userfront.dashboard.activities.error": "연동 정보를 불러오지 못했습니다.",
"msg.userfront.dashboard.approved_device": "승인 기기: {{device}}",
"msg.userfront.dashboard.approved_ip": "승인 IP: {{ip}}",
"msg.userfront.dashboard.approved_session.copy_click": "{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.copy_tap": "{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.copy_click":
"{{label}}: {{id}}\\\\\\\\n클릭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.copy_tap":
"{{label}}: {{id}}\\\\\\\\n탭하면 복사됩니다.",
"msg.userfront.dashboard.approved_session.none": "{{label}} 없음",
"msg.userfront.dashboard.audit_empty": "최근 접속 이력이 없습니다.",
"msg.userfront.dashboard.audit_load_error": "접속이력을 불러오지 못했습니다.",
@@ -187,7 +220,8 @@ const Map<String, String> koStrings = {
"msg.userfront.dashboard.last_auth": "최근 인증: {{value}}",
"msg.userfront.dashboard.link_missing": "이동할 페이지 주소(Client URI)가 설정되지 않았습니다.",
"msg.userfront.dashboard.link_open_error": "해당 링크를 열 수 없습니다.",
"msg.userfront.dashboard.revoke.confirm": "{{app}} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.",
"msg.userfront.dashboard.revoke.confirm":
"{{app}} 앱과의 연동을 해지하시겠습니까?\\\\\\\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.",
"msg.userfront.dashboard.revoke.error": "해지 실패: {{error}}",
"msg.userfront.dashboard.revoke.success": "{{app}} 연동이 해지되었습니다.",
"msg.userfront.dashboard.scopes.empty": "요청된 권한이 없습니다.",
@@ -202,7 +236,8 @@ const Map<String, String> koStrings = {
"msg.userfront.error.title_with_code": "오류: {{code}}",
"msg.userfront.error.type": "오류 종류: {{type}}",
"msg.userfront.error.whitelist.\$normalizedCode": "에러가 계속되면 관리자에게 문의해주세요",
"msg.userfront.forgot.description": "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
"msg.userfront.forgot.description":
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
"msg.userfront.forgot.dry_send": "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.",
"msg.userfront.forgot.error": "전송에 실패했습니다: {{error}}",
"msg.userfront.forgot.input_required": "이메일 또는 휴대폰 번호를 입력해주세요.",
@@ -215,7 +250,8 @@ const Map<String, String> koStrings = {
"msg.userfront.login.link.missing_login_id": "이메일 또는 휴대폰 번호를 입력해 주세요.",
"msg.userfront.login.link.missing_phone": "휴대폰 번호를 입력해 주세요.",
"msg.userfront.login.link.resend_wait": "재발송은 {{time}} 후 가능합니다.",
"msg.userfront.login.link.short_code_help": "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
"msg.userfront.login.link.short_code_help":
"링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
"msg.userfront.login.link_failed": "오류: {{error}}",
"msg.userfront.login.link_send_failed": "전송 실패: {{error}}",
"msg.userfront.login.link_sent_email": "입력하신 이메일로 로그인 링크를 보냈습니다.",
@@ -224,7 +260,8 @@ const Map<String, String> koStrings = {
"msg.userfront.login.no_account": "계정이 없으신가요?",
"msg.userfront.login.oidc_failed": "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.",
"msg.userfront.login.password.failed": "로그인 실패: {{error}}",
"msg.userfront.login.password.missing_credentials": "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.",
"msg.userfront.login.password.missing_credentials":
"이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.",
"msg.userfront.login.qr.load_failed": "QR 코드를 불러오지 못했습니다.",
"msg.userfront.login.qr.scan_hint": "모바일 앱으로 스캔하세요",
"msg.userfront.login.qr_expired": "QR 세션이 만료되었습니다.",
@@ -234,7 +271,8 @@ const Map<String, String> koStrings = {
"msg.userfront.login.token_missing": "로그인 토큰을 확인할 수 없습니다.",
"msg.userfront.login.unregistered.body": "가입되지 않은 정보입니다.\\\\n회원가입 후 이용해 주세요.",
"msg.userfront.login.verification.approved": "승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
"msg.userfront.login.verification.approved_local": "승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
"msg.userfront.login.verification.approved_local":
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
"msg.userfront.login.verification.success": "로그인 승인에 성공했습니다.",
"msg.userfront.login.verification_failed": "승인 처리에 실패했습니다: {{error}}",
"msg.userfront.login_success.subtitle": "성공적으로 로그인되었습니다.",
@@ -272,7 +310,8 @@ const Map<String, String> koStrings = {
"msg.userfront.reset.error.generic": "비밀번호 변경에 실패했습니다: {{error}}",
"msg.userfront.reset.error.lowercase": "최소 1개 이상의 소문자를 포함해야 합니다.",
"msg.userfront.reset.error.min_length": "비밀번호는 최소 {{count}}자 이상이어야 합니다.",
"msg.userfront.reset.error.min_types": "비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
"msg.userfront.reset.error.min_types":
"비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
"msg.userfront.reset.error.mismatch": "비밀번호가 일치하지 않습니다.",
"msg.userfront.reset.error.number": "최소 1개 이상의 숫자를 포함해야 합니다.",
"msg.userfront.reset.error.symbol": "최소 1개 이상의 특수문자를 포함해야 합니다.",
@@ -292,7 +331,8 @@ const Map<String, String> koStrings = {
"msg.userfront.sections.audit_subtitle": "Baron 로그인 기준의 최근 접근 기록입니다.",
"msg.userfront.settings.disabled": "현재 계정 설정 화면은 준비 중입니다.",
"msg.userfront.signup.agreement.title": "서비스 이용을 위해\\\\n약관에 동의해주세요",
"msg.userfront.signup.auth.affiliate_notice": "가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.",
"msg.userfront.signup.auth.affiliate_notice":
"가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.",
"msg.userfront.signup.auth.title": "본인 확인을 위해\\\\n인증을 진행해주세요",
"msg.userfront.signup.email.code_mismatch": "인증코드가 일치하지 않습니다.",
"msg.userfront.signup.email.duplicate": "이미 가입된 이메일입니다.",
@@ -302,7 +342,8 @@ const Map<String, String> koStrings = {
"msg.userfront.signup.email.verify_failed": "인증 실패: {{error}}",
"msg.userfront.signup.failed": "가입 실패: {{error}}",
"msg.userfront.signup.password.length_required": "비밀번호는 최소 12자 이상이어야 합니다.",
"msg.userfront.signup.password.lowercase_required": "소문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.lowercase_required":
"소문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.mismatch": "비밀번호가 일치하지 않습니다.",
"msg.userfront.signup.password.number_required": "숫자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.rule.lowercase": "소문자",
@@ -313,7 +354,8 @@ const Map<String, String> koStrings = {
"msg.userfront.signup.password.rule.uppercase": "대문자",
"msg.userfront.signup.password.symbol_required": "특수문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.title": "마지막으로\\\\n비밀번호를 설정해주세요",
"msg.userfront.signup.password.uppercase_required": "대문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.password.uppercase_required":
"대문자가 최소 1개 이상 포함되어야 합니다.",
"msg.userfront.signup.phone.code_mismatch": "인증코드가 일치하지 않습니다.",
"msg.userfront.signup.phone.send_failed": "발송 실패: {{error}}",
"msg.userfront.signup.phone.verified": "✅ 휴대폰 인증 완료",
@@ -326,14 +368,17 @@ const Map<String, String> koStrings = {
"msg.userfront.signup.policy.summary": "보안 정책: {{rules}}",
"msg.userfront.signup.policy.symbol": "특수문자",
"msg.userfront.signup.policy.uppercase": "대문자",
"msg.userfront.signup.privacy_full": "\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개Line truncated
"msg.userfront.signup.privacy_full":
"\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터Line truncated
"msg.userfront.signup.profile.affiliate_hint": "가족사 이메일 사용 시 자동으로 선택됩니다.",
"msg.userfront.signup.profile.title": "회원님의\\\\n소속 정보를 알려주세요",
"msg.userfront.signup.success.body": "성공적으로 가입되었습니다.",
"msg.userfront.signup.success.title": "회원가입 완료",
"msg.userfront.signup.tos_full": "\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한Line truncated
"msg.userfront.signup.tos_full":
"\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한)\\n회사는 회원이 약Line truncated
"ui.admin.api_keys.create.name_label": "서비스 또는 목적 식별 이름",
"ui.admin.api_keys.create.name_placeholder": "예: Jenkins-CI, Grafana-Dashboard",
"ui.admin.api_keys.create.name_placeholder":
"예: Jenkins-CI, Grafana-Dashboard",
"ui.admin.api_keys.create.section_name": "키 이름 지정",
"ui.admin.api_keys.create.section_scopes": "권한 범위(Scopes) 선택",
"ui.admin.api_keys.create.submit": "API 키 발급하기",
@@ -415,7 +460,8 @@ const Map<String, String> koStrings = {
"ui.admin.tenants.create.breadcrumb.action": "Create",
"ui.admin.tenants.create.breadcrumb.section": "Tenants",
"ui.admin.tenants.create.form.description": "Description",
"ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_label":
"Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr",
"ui.admin.tenants.create.form.name": "Tenant name",
"ui.admin.tenants.create.form.slug": "Slug",
@@ -591,7 +637,8 @@ const Map<String, String> koStrings = {
"ui.dev.clients.details.endpoints.title": "OIDC 엔드포인트",
"ui.dev.clients.details.redirect.callback_label": "인증 콜백 URL",
"ui.dev.clients.details.redirect.label": "Redirect URIs",
"ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.placeholder":
"https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.save": "Redirect URIs 저장",
"ui.dev.clients.details.redirect.title": "리디렉션 URI 설정",
"ui.dev.clients.details.secret.hide": "비밀키 숨기기",
@@ -607,15 +654,18 @@ const Map<String, String> koStrings = {
"ui.dev.clients.general.footer.client_id": "Client ID",
"ui.dev.clients.general.footer.created_on": "Created On",
"ui.dev.clients.general.identity.description": "Description",
"ui.dev.clients.general.identity.description_placeholder": "앱에 대한 간단한 설명을 입력하세요.",
"ui.dev.clients.general.identity.description_placeholder":
"앱에 대한 간단한 설명을 입력하세요.",
"ui.dev.clients.general.identity.logo": "App Logo URL",
"ui.dev.clients.general.identity.logo_placeholder": "https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_placeholder":
"https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_preview": "Logo Preview",
"ui.dev.clients.general.identity.name": "앱 이름",
"ui.dev.clients.general.identity.name_placeholder": "My Awesome Application",
"ui.dev.clients.general.identity.title": "Application Identity",
"ui.dev.clients.general.redirect.label": "Redirect URIs",
"ui.dev.clients.general.redirect.placeholder": "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
"ui.dev.clients.general.redirect.placeholder":
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
"ui.dev.clients.general.save": "설정 저장",
"ui.dev.clients.general.scopes.add": "Scope 추가",
"ui.dev.clients.general.scopes.description_placeholder": "권한에 대한 설명",
@@ -872,7 +922,8 @@ const Map<String, String> enStrings = {
"msg.admin.scope_admin": "Scoped to /admin",
"msg.admin.session_ttl": "Session TTL: 15m admin",
"msg.admin.tenant_headers": "Tenant-aware headers",
"msg.admin.tenants.create.form.domains_help": "Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.form.domains_help":
"Users with these email domains will be automatically assigned to this tenant.",
"msg.admin.tenants.create.memo.body": "Body",
"msg.admin.tenants.create.memo.subtitle": "Subtitle",
"msg.admin.tenants.create.profile.subtitle": "Subtitle",
@@ -882,9 +933,11 @@ const Map<String, String> enStrings = {
"msg.admin.tenants.fetch_error": "Fetch Error",
"msg.admin.tenants.members.empty": "Empty",
"msg.admin.tenants.registry.count": "Count",
"msg.admin.tenants.schema.empty": "No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.empty":
"No custom fields defined. Click \\\"Add Field\\\" to begin.",
"msg.admin.tenants.schema.missing_id": "Tenant ID missing",
"msg.admin.tenants.schema.subtitle": "Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.subtitle":
"Define custom attributes for users in this tenant.",
"msg.admin.tenants.schema.update_error": "Failed to update schema",
"msg.admin.tenants.schema.update_success": "Schema updated successfully",
"msg.admin.tenants.sub.empty": "Empty",
@@ -917,7 +970,8 @@ const Map<String, String> enStrings = {
"msg.dev.clients.consents.empty": "No consents found.",
"msg.dev.clients.consents.load_error": "Error loading consents: {{error}}",
"msg.dev.clients.consents.loading": "Loading consents...",
"msg.dev.clients.consents.showing": "Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.showing":
"Showing {{from}} to {{to}} of {{total}} users",
"msg.dev.clients.consents.subtitle": "Subtitle",
"msg.dev.clients.copy_client_id": "Copy Client Id",
"msg.dev.clients.details.copy_client_id": "Client ID copied.",
@@ -947,8 +1001,10 @@ const Map<String, String> enStrings = {
"msg.dev.clients.general.security.confidential_help": "Confidential Help",
"msg.dev.clients.general.security.public_help": "Public Help",
"msg.dev.clients.general.security.subtitle": "Subtitle",
"msg.dev.clients.help.docs_body": "Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle": "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.help.docs_body":
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
"msg.dev.clients.help.subtitle":
"Developer guides for Confidential/Public clients, redirect URIs, and auth methods.",
"msg.dev.clients.load_error": "Error loading clients: {{error}}",
"msg.dev.clients.loading": "Loading clients...",
"msg.dev.clients.registry.description": "Description",
@@ -1132,12 +1188,14 @@ const Map<String, String> enStrings = {
"msg.userfront.signup.policy.summary": "Summary",
"msg.userfront.signup.policy.symbol": "Symbol",
"msg.userfront.signup.policy.uppercase": "Uppercase",
"msg.userfront.signup.privacy_full": "\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개Line truncated
"msg.userfront.signup.privacy_full":
"\\n개인정보 수집 및 이용 동의\\n\\n바론서비스 개인정보처리방침\\n\\n제1조 (목적)\\n바론컨설턴트(이하 \\\"회사\\\")는 바론서비스(이하 \\\"서비스\\\")를 이용하는 고객(이하 \\\"이용자\\\")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.\\n제2조 (개인정보의 처리목적)\\n회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\\n- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락\\n- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리\\n- 제품소개서 다운로드: 설명자료 전달\\n- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집\\n- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공\\n- 보안가이드 제공: 안내자료 전달\\n- 기술지원 문의: 서비스 사용 지원\\n- 서비스 개선 의견 접수: 서비스 품질 개선\\n- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송\\n제3조 (개인정보의 처리 및 보유 기간)\\n① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.\\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:\\n- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지\\n- 홍보, 상담, 계약용 개인정보: 2년\\n제4조 (개인정보의 제3자 제공)\\n① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.\\n② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:\\n- 제공받는 자: 수사기관 및 유관기관, 피신고업체\\n- 이용 목적: 개인정보 침해 민원 처리\\n- 제공하는 개인정보 항목: 성명, 연락처, 이메일\\n- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기\\n제5조 (개인정보 처리 위탁)\\n① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.\\n② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.\\n제6조 (정보주체의 권리·의무 및 행사 방법)\\n① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.\\n② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:\\n- 서면: 회사 주소로 서면 제출\\n- 전자우편: 회사 이메일로 요청\\n- 모사전송(FAX): 회사 FAX로 요청\\n③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.\\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.\\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.\\n⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.\\n제7조 (처리하는 개인정보의 항목)\\n회사는 다음의 개인정보 항목을 처리합니다:\\n- 수집 항목:\\n- 필수 항목: 성명, 휴대전화번호, 이메일\\n- 선택 항목: 회사전화번호, 문의사항\\n- 수집 방법:\\n- 홈페이지, 전화, 이메일을 통해 수집\\n제8조 (개인정보의 파기)\\n① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.\\n② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터Line truncated
"msg.userfront.signup.profile.affiliate_hint": "Affiliate Hint",
"msg.userfront.signup.profile.title": "Title",
"msg.userfront.signup.success.body": "Body",
"msg.userfront.signup.success.title": "Title",
"msg.userfront.signup.tos_full": "\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한Line truncated
"msg.userfront.signup.tos_full":
"\\n바론 소프트웨어 이용약관\\n\\n제1장 총칙\\n제1조 (목적)\\n이 약관은 바론컨설턴트(이하 \\\"회사\\\"라 합니다)가 제공하는 바론소프트웨어(이하 \\\"서비스\\\"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.\\n제2조 (용어의 정의)\\n① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:\\n- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.\\n- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.\\n- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.\\n- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.\\n제3조 (약관의 효력 및 변경)\\n① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.\\n제4조 (약관 외 준칙)\\n본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.\\n제2장 서비스 이용계약\\n제5조 (이용계약의 성립)\\n이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.\\n제6조 (이용계약의 유보와 거절)\\n① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우\\n제7조 (계약사항의 변경)\\n회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.\\n제3장 개인정보 보호\\n제8조 (개인정보 보호의 원칙)\\n① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.\\n제9조 (개인정보처리방침 준수)\\n① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.\\n제10조 (14세 미만 아동의 개인정보 보호)\\n① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.\\n제4장 서비스 제공 및 이용\\n제11조 (서비스 제공)\\n회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.\\n제12조 (서비스의 변경 및 중단)\\n회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.\\n제5장 정보 제공 및 광고\\n제13조 (정보 제공 및 광고)\\n① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.\\n제6장 게시물 관리\\n제14조 (게시물의 관리)\\n회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.\\n제15조 (게시물의 저작권)\\n게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.\\n제7장 계약 해지 및 이용 제한\\n제16조 (계약 해지)\\n회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.\\n제17조 (이용 제한)\\n회사는 회원이 약Line truncated
"ui.admin.api_keys.create.name_label": "Name Label",
"ui.admin.api_keys.create.name_placeholder": "Name Placeholder",
"ui.admin.api_keys.create.section_name": "Section Name",
@@ -1221,7 +1279,8 @@ const Map<String, String> enStrings = {
"ui.admin.tenants.create.breadcrumb.action": "Create",
"ui.admin.tenants.create.breadcrumb.section": "Tenants",
"ui.admin.tenants.create.form.description": "Description",
"ui.admin.tenants.create.form.domains_label": "Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_label":
"Allowed Domains (Comma separated)",
"ui.admin.tenants.create.form.domains_placeholder": "example.com, example.kr",
"ui.admin.tenants.create.form.name": "Tenant name",
"ui.admin.tenants.create.form.slug": "Slug",
@@ -1397,7 +1456,8 @@ const Map<String, String> enStrings = {
"ui.dev.clients.details.endpoints.title": "Title",
"ui.dev.clients.details.redirect.callback_label": "Callback Label",
"ui.dev.clients.details.redirect.label": "Redirect URIs",
"ui.dev.clients.details.redirect.placeholder": "https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.placeholder":
"https://your-app.com/callback, http://localhost:3000/auth/callback",
"ui.dev.clients.details.redirect.save": "Save",
"ui.dev.clients.details.redirect.title": "Title",
"ui.dev.clients.details.secret.hide": "Hide",
@@ -1413,9 +1473,11 @@ const Map<String, String> enStrings = {
"ui.dev.clients.general.footer.client_id": "Client ID",
"ui.dev.clients.general.footer.created_on": "Created On",
"ui.dev.clients.general.identity.description": "Description",
"ui.dev.clients.general.identity.description_placeholder": "Description Placeholder",
"ui.dev.clients.general.identity.description_placeholder":
"Description Placeholder",
"ui.dev.clients.general.identity.logo": "App Logo URL",
"ui.dev.clients.general.identity.logo_placeholder": "https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_placeholder":
"https://example.com/logo.png",
"ui.dev.clients.general.identity.logo_preview": "Logo Preview",
"ui.dev.clients.general.identity.name": "Name",
"ui.dev.clients.general.identity.name_placeholder": "My Awesome Application",
@@ -1424,7 +1486,8 @@ const Map<String, String> enStrings = {
"ui.dev.clients.general.redirect.placeholder": "Placeholder",
"ui.dev.clients.general.save": "Settings Save",
"ui.dev.clients.general.scopes.add": "Scope Add",
"ui.dev.clients.general.scopes.description_placeholder": "Description Placeholder",
"ui.dev.clients.general.scopes.description_placeholder":
"Description Placeholder",
"ui.dev.clients.general.scopes.name_placeholder": "e.g. profile",
"ui.dev.clients.general.scopes.table.description": "Description",
"ui.dev.clients.general.scopes.table.mandatory": "Mandatory",
+35 -30
View File
@@ -46,7 +46,9 @@ void main() async {
FlutterError.presentError(details);
_log.severe("FLUTTER_ERROR", details.exception, details.stack);
// Also send to backend if needed
AuthProxyService.logError("FLUTTER_ERROR: ${details.exception}\n${details.stack}");
AuthProxyService.logError(
"FLUTTER_ERROR: ${details.exception}\n${details.stack}",
);
};
PlatformDispatcher.instance.onError = (error, stack) {
@@ -86,18 +88,19 @@ final _router = GoRouter(
return const DashboardScreen();
},
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
),
GoRoute(path: '/profile', builder: (context, state) => const ProfilePage()),
GoRoute(
path: '/signin',
builder: (context, state) {
final loginChallenge = state.uri.queryParameters['login_challenge'];
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url'];
_routerLogger.info("Navigating to /signin with login_challenge: $loginChallenge, redirect: $redirectUrl");
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
_routerLogger.info(
"Navigating to /signin with login_challenge: $loginChallenge, redirect: $redirectUrl",
);
return LoginScreen(
key: state.pageKey,
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
);
@@ -106,12 +109,11 @@ final _router = GoRouter(
GoRoute(
path: '/login',
builder: (context, state) {
final redirectUrl = state.uri.queryParameters['redirect_uri'] ?? state.uri.queryParameters['redirect_url'];
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
_routerLogger.info("Navigating to /login, redirect: $redirectUrl");
return LoginScreen(
key: state.pageKey,
redirectUrl: redirectUrl,
);
return LoginScreen(key: state.pageKey, redirectUrl: redirectUrl);
},
),
GoRoute(
@@ -120,7 +122,9 @@ final _router = GoRouter(
final consentChallenge = state.uri.queryParameters['consent_challenge'];
if (consentChallenge == null) {
_routerLogger.warning("Consent screen loaded without a challenge.");
return const Scaffold(body: Center(child: Text('Error: Consent challenge is missing.')));
return const Scaffold(
body: Center(child: Text('Error: Consent challenge is missing.')),
);
}
_routerLogger.info("Navigating to /consent with challenge.");
return ConsentScreen(consentChallenge: consentChallenge);
@@ -257,21 +261,22 @@ final _router = GoRouter(
final path = state.uri.path;
// Public paths that don't require login
final isPublicPath = path == '/signin' ||
path == '/signup' ||
path == '/login' ||
path == '/registration' ||
path == '/verify' ||
path == '/verification' ||
path.startsWith('/verify/') ||
path == '/approve' ||
path.startsWith('/ql/') ||
path == '/forgot-password' ||
path == '/recovery' ||
path == '/reset-password' ||
path == '/error' ||
path == '/settings' ||
path == '/consent'; // Consent page is public
final isPublicPath =
path == '/signin' ||
path == '/signup' ||
path == '/login' ||
path == '/registration' ||
path == '/verify' ||
path == '/verification' ||
path.startsWith('/verify/') ||
path == '/approve' ||
path.startsWith('/ql/') ||
path == '/forgot-password' ||
path == '/recovery' ||
path == '/reset-password' ||
path == '/error' ||
path == '/settings' ||
path == '/consent'; // Consent page is public
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
@@ -282,7 +287,7 @@ final _router = GoRouter(
// If not logged in and trying to access a protected page, redirect to /signin
if (!isLoggedIn) {
_routerLogger.info("Not logged in, redirecting to /signin");
_routerLogger.info("Not logged in, redirecting to /signin");
// Preserve OIDC challenge if present
final loginChallenge = state.uri.queryParameters['login_challenge'];
if (loginChallenge != null) {
+23 -23
View File
@@ -1,7 +1,7 @@
<!doctype html>
<html>
<head>
<!--
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
@@ -14,29 +14,29 @@
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="/" />
<base href="/" />
<meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="바론 SW 포털" />
<meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="바론 SW 포털" />
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Baron 로그인" />
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Baron 로그인" />
<link rel="apple-touch-icon" href="icons/Icon-192.png" />
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png" />
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png" />
<title>Baron 로그인</title>
<link rel="manifest" href="manifest.json" />
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
<title>Baron 로그인</title>
<link rel="manifest" href="manifest.json" />
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+33 -33
View File
@@ -1,35 +1,35 @@
{
"name": "Baron 로그인",
"short_name": "Baron 로그인",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Baron 로그인 사용자 포털.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
"name": "Baron 로그인",
"short_name": "Baron 로그인",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Baron 로그인 사용자 포털.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}