forked from baron/baron-sso
Merge branch 'feature/tenant-group-239' into dev
This commit is contained in:
@@ -88,6 +88,8 @@ HYDRA_VERSION=v25.4.0-distroless
|
|||||||
|
|
||||||
# Ory Keto Configuration
|
# Ory Keto Configuration
|
||||||
KETO_VERSION=v25.4.0-distroless
|
KETO_VERSION=v25.4.0-distroless
|
||||||
|
KETO_READ_URL=http://keto:4466
|
||||||
|
KETO_WRITE_URL=http://keto:4467
|
||||||
# KETO_READ_PORT=4466 # Internal only
|
# KETO_READ_PORT=4466 # Internal only
|
||||||
# KETO_WRITE_PORT=4467 # Internal only
|
# KETO_WRITE_PORT=4467 # Internal only
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,9 +3,18 @@ import AppLayout from "../components/layout/AppLayout";
|
|||||||
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
||||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
|
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||||
import AuthPage from "../features/auth/AuthPage";
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
|
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 TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
|
||||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
@@ -17,6 +26,14 @@ import UserListPage from "../features/users/UserListPage";
|
|||||||
|
|
||||||
export const router = createBrowserRouter(
|
export const router = createBrowserRouter(
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/auth/callback",
|
||||||
|
element: <AuthCallbackPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <AppLayout />,
|
element: <AppLayout />,
|
||||||
@@ -30,11 +47,23 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "users/:id", element: <UserDetailPage /> },
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
|
{ path: "tenant-groups", element: <TenantGroupListPage /> },
|
||||||
|
{ path: "tenant-groups/new", element: <TenantGroupCreatePage /> },
|
||||||
|
{
|
||||||
|
path: "tenant-groups/:id",
|
||||||
|
element: <TenantGroupDetailPage />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <TenantGroupProfileTab /> },
|
||||||
|
{ path: "tenants", element: <TenantGroupTenantsTab /> },
|
||||||
|
{ path: "admins", element: <TenantGroupAdminsTab /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "tenants/:tenantId",
|
path: "tenants/:tenantId",
|
||||||
element: <TenantDetailPage />,
|
element: <TenantDetailPage />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <TenantProfilePage /> },
|
{ index: true, element: <TenantProfilePage /> },
|
||||||
|
{ path: "admins", element: <TenantAdminsTab /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
LayoutGrid,
|
||||||
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
|
Rocket,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
@@ -22,19 +25,40 @@ const navItems = [
|
|||||||
to: "/dashboard",
|
to: "/dashboard",
|
||||||
icon: ShieldHalf,
|
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.tenants", to: "/tenants", icon: Building2 },
|
||||||
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
|
||||||
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
|
{ 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.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
const stored = window.localStorage.getItem("admin_theme");
|
||||||
return stored === "dark" ? "dark" : "light";
|
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(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("light", "dark");
|
root.classList.remove("light", "dark");
|
||||||
@@ -72,36 +96,50 @@ function AppLayout() {
|
|||||||
{t("msg.admin.scope_admin", "Scoped to /admin")}
|
{t("msg.admin.scope_admin", "Scoped to /admin")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
<nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
|
||||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
<div className="space-y-1">
|
||||||
<span className="rounded-full border border-border px-3 py-1">
|
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||||
{t("msg.admin.idp_env_prod", "IDP env: prod")}
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
</span>
|
{t("msg.admin.idp_env_prod", "IDP env: prod")}
|
||||||
<span className="rounded-full border border-border px-3 py-1">
|
</span>
|
||||||
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
</span>
|
{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>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{navItems.map(({ label, to, icon: Icon }) => (
|
<div className="px-3 pt-4 border-t border-border/50">
|
||||||
<NavLink
|
<button
|
||||||
key={to}
|
type="button"
|
||||||
to={to}
|
onClick={handleLogout}
|
||||||
className={({ isActive }) =>
|
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"
|
||||||
[
|
>
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
<LogOut size={18} />
|
||||||
isActive
|
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
</button>
|
||||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
|
||||||
].join(" ")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon size={18} />
|
|
||||||
<span>{t(label, label)}</span>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
|
||||||
|
{" "}
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.notice.scope",
|
"msg.admin.notice.scope",
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const badgeVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const buttonVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface TextareaProps
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
|
|||||||
43
adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
43
adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { ShieldHalf } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
function AuthCallbackPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
if (token) {
|
||||||
|
window.localStorage.setItem("admin_session", token);
|
||||||
|
|
||||||
|
// 만약 팝업창에서 실행 중이라면 부모 창에 알리고 닫기
|
||||||
|
if (window.opener) {
|
||||||
|
window.opener.postMessage({ type: "LOGIN_SUCCESS", token }, "*");
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
// 일반 리다이렉트 방식인 경우 홈으로 이동
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("No token found in callback URL");
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}
|
||||||
|
}, [navigate, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-lg animate-pulse">
|
||||||
|
<ShieldHalf size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold">인증 완료 중...</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
세션을 동기화하고 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthCallbackPage;
|
||||||
132
adminfront/src/features/auth/LoginPage.tsx
Normal file
132
adminfront/src/features/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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 {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for login success message from the popup
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
// Security check: In production, verify event.origin
|
||||||
|
if (event.data?.type === "LOGIN_SUCCESS" && event.data?.token) {
|
||||||
|
window.localStorage.setItem("admin_session", event.data.token);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
return () => window.removeEventListener("message", handleMessage);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const top = window.screen.height / 2 - height / 2;
|
||||||
|
|
||||||
|
const popup = window.open(
|
||||||
|
loginUrl,
|
||||||
|
"BaronSSOLogin",
|
||||||
|
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (popup.closed) {
|
||||||
|
clearInterval(timer);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
alert("팝업 차단이 설정되어 있습니다. 팝업 허용 후 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
|
||||||
|
<ShieldHalf size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
|
||||||
|
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
|
||||||
|
Admin Control Plane
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
관리자 로그인
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
로그인 진행 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldHalf size={22} />
|
||||||
|
SSO 계정으로 로그인
|
||||||
|
<ExternalLink size={16} className="opacity-50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||||
|
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||||
|
<br />
|
||||||
|
민감한 작업 시 재인증을 요구할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<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 />
|
||||||
|
시스템 관리자에게 문의하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
import PermissionChecker from "./components/PermissionChecker";
|
||||||
|
|
||||||
const summaryCards = [
|
const summaryCards = [
|
||||||
{
|
{
|
||||||
@@ -216,6 +217,8 @@ function GlobalOverviewPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PermissionChecker />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import apiClient from "../../../lib/apiClient";
|
||||||
|
|
||||||
|
type CheckPermissionResponse = {
|
||||||
|
allowed: boolean;
|
||||||
|
query: {
|
||||||
|
namespace: string;
|
||||||
|
object: string;
|
||||||
|
relation: string;
|
||||||
|
subject: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function PermissionChecker() {
|
||||||
|
const [namespace, setNamespace] = useState("Tenant");
|
||||||
|
const [object, setObject] = useState("");
|
||||||
|
const [relation, setRelation] = useState("manage");
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
|
||||||
|
const checkMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await apiClient.get<CheckPermissionResponse>(
|
||||||
|
"/v1/admin/debug/check-permission",
|
||||||
|
{
|
||||||
|
params: { namespace, object, relation, subject },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = checkMutation.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-[var(--color-panel)] border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert size={20} className="text-primary" />
|
||||||
|
ReBAC 권한 검증 도구
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
특정 주체(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"
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Object ID</Label>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionChecker;
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import {
|
||||||
|
type TenantGroupSummary,
|
||||||
|
addGroupAdmin,
|
||||||
|
fetchGroupAdmins,
|
||||||
|
fetchUsers,
|
||||||
|
removeGroupAdmin,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantGroupAdminsTab() {
|
||||||
|
const { group } = useOutletContext<{
|
||||||
|
group: TenantGroupSummary;
|
||||||
|
}>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 현재 관리자 목록
|
||||||
|
const adminsQuery = useQuery({
|
||||||
|
queryKey: ["tenant-group-admins", group.id],
|
||||||
|
queryFn: () => fetchGroupAdmins(group.id),
|
||||||
|
enabled: !!group.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전체 사용자 목록 (관리자 추가용)
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users", { limit: 100, search: searchTerm }],
|
||||||
|
queryFn: () => fetchUsers(100, 0, searchTerm),
|
||||||
|
enabled: searchTerm.length > 1, // 2글자 이상 입력 시 검색
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => addGroupAdmin(group.id, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
adminsQuery.refetch();
|
||||||
|
setSearchTerm("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => removeGroupAdmin(group.id, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
adminsQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddAdmin = (userId: string) => {
|
||||||
|
addMutation.mutate(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||||
|
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
|
||||||
|
removeMutation.mutate(userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* 현재 그룹 관리자 */}
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck size={18} className="text-primary" />
|
||||||
|
그룹 관리자
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
이 그룹과 소속 테넌트를 관리할 수 있는 권한을 가진 사용자들입니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>이름</TableHead>
|
||||||
|
<TableHead>이메일</TableHead>
|
||||||
|
<TableHead className="text-right">회수</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{adminsQuery.data?.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<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="text-xs">{admin.email}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사용자 검색 및 추가 */}
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus size={18} className="text-primary" />
|
||||||
|
관리자 추가
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>사용자</TableHead>
|
||||||
|
<TableHead className="text-right">추가</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{searchTerm.length < 2 && (
|
||||||
|
<TableRow>
|
||||||
|
<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"
|
||||||
|
onClick={() => handleAddAdmin(user.id)}
|
||||||
|
disabled={addMutation.isPending}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantGroupAdminsTab;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { LayoutGrid, Sparkles } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
|
import { createTenantGroup } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantGroupCreatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createTenantGroup({
|
||||||
|
name,
|
||||||
|
slug: slug || name.toLowerCase().replace(/ /g, "-"),
|
||||||
|
description: description || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("/tenant-groups");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||||
|
<span>Tenants</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Groups</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">Create</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-semibold">테넌트 그룹 추가</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
여러 테넌트를 논리적으로 묶어 관리하기 위한 그룹을 생성합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Super Admin only</Badge>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<LayoutGrid size={18} className="text-primary" />
|
||||||
|
Group Profile
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
그룹 이름과 식별자(Slug)를 입력합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
Group Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="예: 바론소프트웨어 통합그룹"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Slug</Label>
|
||||||
|
<Input
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
placeholder="baron-group"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로
|
||||||
|
자동 생성됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="그룹에 대한 설명을 입력하세요."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
권한 상속 안내
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을
|
||||||
|
자동으로 가집니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-[var(--color-muted)]">
|
||||||
|
생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => navigate("/tenant-groups")}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
disabled={mutation.isPending || name.trim() === ""}
|
||||||
|
>
|
||||||
|
생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantGroupCreatePage;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft, LayoutGrid } from "lucide-react";
|
||||||
|
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { fetchTenantGroup } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantGroupDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const groupQuery = useQuery({
|
||||||
|
queryKey: ["tenant-group", id],
|
||||||
|
queryFn: () => fetchTenantGroup(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTenantsTab = location.pathname.endsWith("/tenants");
|
||||||
|
const isAdminTab = location.pathname.endsWith("/admins");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Groups
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">Detail</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<LayoutGrid size={24} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">
|
||||||
|
{groupQuery.data?.name ?? "Loading Group..."}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
{groupQuery.data?.description ||
|
||||||
|
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Super Admin only</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-border">
|
||||||
|
<Link
|
||||||
|
to={`/tenant-groups/${id}`}
|
||||||
|
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||||
|
!isTenantsTab && !isAdminTab
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
기본 정보
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/tenant-groups/${id}/tenants`}
|
||||||
|
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isTenantsTab
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
소속 테넌트 ({groupQuery.data?.tenants?.length ?? 0})
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/tenant-groups/${id}/admins`}
|
||||||
|
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isAdminTab
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
관리자
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Outlet
|
||||||
|
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantGroupDetailPage;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import { deleteTenantGroup, fetchTenantGroups } from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantGroupListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["tenant-groups", { limit: 50, offset: 0 }],
|
||||||
|
queryFn: () => fetchTenantGroups(50, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (groupId: string) => deleteTenantGroup(groupId),
|
||||||
|
onSuccess: () => {
|
||||||
|
query.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
const fallbackError =
|
||||||
|
!errorMsg && query.isError ? "테넌트 그룹 목록 조회에 실패했습니다." : null;
|
||||||
|
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
|
const handleDelete = (groupId: string, groupName: string) => {
|
||||||
|
if (!window.confirm(`테넌트 그룹 "${groupName}"를 삭제할까요?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate(groupId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<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)]">
|
||||||
|
<span>Tenants</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-foreground">Groups</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-semibold">테넌트 그룹 목록</h2>
|
||||||
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
|
여러 테넌트를 하나의 그룹으로 묶어 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/tenant-groups/new">
|
||||||
|
<Plus size={16} />
|
||||||
|
그룹 추가
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<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
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
총 {query.data?.total ?? 0}개 그룹
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant="muted">Super Admin only</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{(errorMsg || fallbackError) && (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg ?? fallbackError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>NAME</TableHead>
|
||||||
|
<TableHead>SLUG</TableHead>
|
||||||
|
<TableHead>TENANTS</TableHead>
|
||||||
|
<TableHead>CREATED</TableHead>
|
||||||
|
<TableHead className="text-right">ACTIONS</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{query.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5}>
|
||||||
|
아직 등록된 테넌트 그룹이 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((group) => (
|
||||||
|
<TableRow key={group.id}>
|
||||||
|
<TableCell className="font-semibold">{group.name}</TableCell>
|
||||||
|
<TableCell>{group.slug}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{group.tenants?.length ?? 0}개
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{group.createdAt
|
||||||
|
? new Date(group.createdAt).toLocaleDateString("ko-KR")
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/tenant-groups/${group.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
관리
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(group.id, group.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantGroupListPage;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useOutletContext } from "react-router-dom";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
|
import {
|
||||||
|
type TenantGroupSummary,
|
||||||
|
updateTenantGroup,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantGroupProfileTab() {
|
||||||
|
const { group, refetch } = useOutletContext<{
|
||||||
|
group: TenantGroupSummary;
|
||||||
|
refetch: () => void;
|
||||||
|
}>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [name, setName] = useState(group?.name ?? "");
|
||||||
|
const [description, setDescription] = useState(group?.description ?? "");
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => updateTenantGroup(group.id, { name, description }),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-groups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
|
||||||
|
?.data?.error;
|
||||||
|
|
||||||
|
if (!group) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>그룹 정보 수정</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
그룹의 기본 이름과 설명을 변경할 수 있습니다. 식별자(Slug)는 변경할
|
||||||
|
수 없습니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹 ID (고유 식별자)</Label>
|
||||||
|
<Input value={group.id} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Slug</Label>
|
||||||
|
<Input value={group.slug} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="groupName">Group Name</Label>
|
||||||
|
<Input
|
||||||
|
id="groupName"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="groupDesc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="groupDesc"
|
||||||
|
rows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
mutation.isPending ||
|
||||||
|
(name === group.name && description === group.description)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "저장 중..." : "변경사항 저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantGroupProfileTab;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
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,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import {
|
||||||
|
type TenantGroupSummary,
|
||||||
|
addTenantToGroup,
|
||||||
|
fetchTenants,
|
||||||
|
removeTenantFromGroup,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantGroupTenantsTab() {
|
||||||
|
const { group, refetch } = useOutletContext<{
|
||||||
|
group: TenantGroupSummary;
|
||||||
|
refetch: () => void;
|
||||||
|
}>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 전체 테넌트 목록 (할당용)
|
||||||
|
const tenantsQuery = useQuery({
|
||||||
|
queryKey: ["tenants", { limit: 100 }],
|
||||||
|
queryFn: () => fetchTenants(100, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (tenantId: string) => addTenantToGroup(group.id, tenantId),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (tenantId: string) => removeTenantFromGroup(group.id, tenantId),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddTenant = (tenantId: string) => {
|
||||||
|
addMutation.mutate(tenantId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTenant = (tenantId: string) => {
|
||||||
|
if (window.confirm("이 테넌트를 그룹에서 제외할까요?")) {
|
||||||
|
removeMutation.mutate(tenantId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* 현재 소속 테넌트 */}
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 size={18} className="text-primary" />
|
||||||
|
소속 테넌트
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
현재 이 그룹에 포함된 테넌트 목록입니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>이름</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead className="text-right">제외</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{group.tenants?.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
소속된 테넌트가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{group.tenants?.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 추가 가능한 테넌트 */}
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
다른 그룹에 속하지 않았거나 이동이 필요한 테넌트를 선택하세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>이름</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">추가</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredAvailable.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
추가할 수 있는 테넌트가 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filteredAvailable.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{t.name}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{t.slug}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{t.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddTenant(t.id)}
|
||||||
|
disabled={addMutation.isPending}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantGroupTenantsTab;
|
||||||
214
adminfront/src/features/tenants/routes/TenantAdminsTab.tsx
Normal file
214
adminfront/src/features/tenants/routes/TenantAdminsTab.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import {
|
||||||
|
addTenantAdmin,
|
||||||
|
fetchTenantAdmins,
|
||||||
|
fetchUsers,
|
||||||
|
removeTenantAdmin,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
|
function TenantAdminsTab() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
if (!tenantId) return null;
|
||||||
|
|
||||||
|
// 현재 관리자 목록
|
||||||
|
const adminsQuery = useQuery({
|
||||||
|
queryKey: ["tenant-admins", tenantId],
|
||||||
|
queryFn: () => fetchTenantAdmins(tenantId),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전체 사용자 목록 (관리자 추가용)
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users", { limit: 100, search: searchTerm }],
|
||||||
|
queryFn: () => fetchUsers(100, 0, searchTerm),
|
||||||
|
enabled: searchTerm.length > 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
adminsQuery.refetch();
|
||||||
|
setSearchTerm("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
adminsQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddAdmin = (userId: string) => {
|
||||||
|
addMutation.mutate(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||||
|
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
|
||||||
|
removeMutation.mutate(userId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2 mt-6">
|
||||||
|
{/* 현재 테넌트 관리자 */}
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck size={18} className="text-primary" />
|
||||||
|
테넌트 관리자
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>이름</TableHead>
|
||||||
|
<TableHead>이메일</TableHead>
|
||||||
|
<TableHead className="text-right">회수</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{adminsQuery.data?.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<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="text-xs">{admin.email}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 사용자 검색 및 추가 */}
|
||||||
|
<Card className="bg-[var(--color-panel)]">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus size={18} className="text-primary" />
|
||||||
|
관리자 추가
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>사용자</TableHead>
|
||||||
|
<TableHead className="text-right">추가</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{searchTerm.length < 2 && (
|
||||||
|
<TableRow>
|
||||||
|
<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"
|
||||||
|
onClick={() => handleAddAdmin(user.id)}
|
||||||
|
disabled={addMutation.isPending}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TenantAdminsTab;
|
||||||
@@ -16,6 +16,7 @@ function TenantDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isFederationTab = location.pathname.includes("/federation");
|
const isFederationTab = location.pathname.includes("/federation");
|
||||||
|
const isAdminTab = location.pathname.includes("/admins");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -44,7 +45,9 @@ function TenantDetailPage() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/tenants/${tenantId}`}
|
to={`/tenants/${tenantId}`}
|
||||||
className={`px-4 py-2 text-sm font-medium ${
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
!isFederationTab
|
!isFederationTab &&
|
||||||
|
!isAdminTab &&
|
||||||
|
!location.pathname.includes("/schema")
|
||||||
? "border-b-2 border-blue-500 text-blue-600"
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
: "text-gray-500 hover:text-gray-700"
|
: "text-gray-500 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
@@ -61,6 +64,16 @@ function TenantDetailPage() {
|
|||||||
>
|
>
|
||||||
Federation
|
Federation
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenantId}/admins`}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
isAdminTab
|
||||||
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Admins
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/tenants/${tenantId}/schema`}
|
to={`/tenants/${tenantId}/schema`}
|
||||||
className={`px-4 py-2 text-sm font-medium ${
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
approveTenant,
|
approveTenant,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
|
fetchTenantGroups,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
@@ -35,11 +36,17 @@ export function TenantProfilePage() {
|
|||||||
queryFn: () => fetchTenant(tenantId),
|
queryFn: () => fetchTenant(tenantId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ["tenant-groups", { limit: 100 }],
|
||||||
|
queryFn: () => fetchTenantGroups(100, 0),
|
||||||
|
});
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("active");
|
const [status, setStatus] = useState("active");
|
||||||
const [domains, setDomains] = useState("");
|
const [domains, setDomains] = useState("");
|
||||||
|
const [tenantGroupId, setTenantGroupId] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tenantQuery.data) {
|
if (tenantQuery.data) {
|
||||||
@@ -48,6 +55,7 @@ export function TenantProfilePage() {
|
|||||||
setDescription(tenantQuery.data.description ?? "");
|
setDescription(tenantQuery.data.description ?? "");
|
||||||
setStatus(tenantQuery.data.status);
|
setStatus(tenantQuery.data.status);
|
||||||
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
|
||||||
|
setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
|
||||||
}
|
}
|
||||||
}, [tenantQuery.data]);
|
}, [tenantQuery.data]);
|
||||||
|
|
||||||
@@ -58,6 +66,7 @@ export function TenantProfilePage() {
|
|||||||
slug,
|
slug,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
status,
|
status,
|
||||||
|
tenantGroupId: tenantGroupId || undefined,
|
||||||
domains: domains
|
domains: domains
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((d) => d.trim())
|
.map((d) => d.trim())
|
||||||
@@ -136,6 +145,25 @@ export function TenantProfilePage() {
|
|||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Tenant Group</Label>
|
||||||
|
<select
|
||||||
|
value={tenantGroupId}
|
||||||
|
onChange={(e) => setTenantGroupId(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 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">그룹 없음</option>
|
||||||
|
{groupsQuery.data?.items.map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
{group.name} ({group.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
테넌트가 속할 그룹을 지정합니다. 그룹 관리자는 소속 테넌트에 대한
|
||||||
|
접근 권한을 가집니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
Allowed Domains (Comma separated)
|
Allowed Domains (Comma separated)
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||||
background-image: radial-gradient(
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
circle at 10% 18%,
|
circle at 10% 18%,
|
||||||
rgba(54, 211, 153, 0.16),
|
rgba(54, 211, 153, 0.16),
|
||||||
transparent 28%
|
transparent 28%
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type TenantSummary = {
|
|||||||
status: string;
|
status: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
|
tenantGroupId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -38,6 +39,7 @@ export type TenantCreateRequest = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
|
tenantGroupId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantListResponse = {
|
export type TenantListResponse = {
|
||||||
@@ -54,6 +56,7 @@ export type TenantUpdateRequest = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
domains?: string[];
|
domains?: string[];
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
|
tenantGroupId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiKeySummary = {
|
export type ApiKeySummary = {
|
||||||
@@ -92,6 +95,26 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
export type LoginRequest = {
|
||||||
|
loginId: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponse = {
|
||||||
|
sessionToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function login(payload: LoginRequest) {
|
||||||
|
const { data } = await apiClient.post<LoginResponse>(
|
||||||
|
"/v1/auth/password/login",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
||||||
const { data } = await apiClient.get<TenantListResponse>(
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
"/v1/admin/tenants",
|
"/v1/admin/tenants",
|
||||||
@@ -139,7 +162,7 @@ export async function approveTenant(tenantId: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group Management
|
// User Group Management (Within a Tenant)
|
||||||
export type GroupMember = {
|
export type GroupMember = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -191,6 +214,123 @@ export async function removeGroupMember(groupId: string, userId: string) {
|
|||||||
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
|
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tenant Group Management (Global Grouping of Tenants)
|
||||||
|
export type TenantGroupSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
tenants?: TenantSummary[];
|
||||||
|
config?: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantGroupListResponse = {
|
||||||
|
items: TenantGroupSummary[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchTenantGroups(limit = 50, offset = 0) {
|
||||||
|
const { data } = await apiClient.get<TenantGroupListResponse>(
|
||||||
|
"/v1/admin/tenant-groups",
|
||||||
|
{
|
||||||
|
params: { limit, offset },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantGroup(id: string) {
|
||||||
|
const { data } = await apiClient.get<TenantGroupSummary>(
|
||||||
|
`/v1/admin/tenant-groups/${id}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenantGroup(payload: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
const { data } = await apiClient.post<TenantGroupSummary>(
|
||||||
|
"/v1/admin/tenant-groups",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenantGroup(
|
||||||
|
id: string,
|
||||||
|
payload: { name: string; description?: string },
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.put<TenantGroupSummary>(
|
||||||
|
`/v1/admin/tenant-groups/${id}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenantGroup(id: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenant-groups/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantToGroup(groupId: string, tenantId: string) {
|
||||||
|
await apiClient.post(
|
||||||
|
`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantFromGroup(groupId: string, tenantId: string) {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TenantAdmin = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchTenantAdmins(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<TenantAdmin[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/admins`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantAdmin(tenantId: string, userId: string) {
|
||||||
|
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantAdmin(tenantId: string, userId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupAdmin = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchGroupAdmins(groupId: string) {
|
||||||
|
const { data } = await apiClient.get<GroupAdmin[]>(
|
||||||
|
`/v1/admin/tenant-groups/${groupId}/admins`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addGroupAdmin(groupId: string, userId: string) {
|
||||||
|
await apiClient.post(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGroupAdmin(groupId: string, userId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// API Key Management (M2M)
|
// API Key Management (M2M)
|
||||||
export type ApiKeyCreateRequest = {
|
export type ApiKeyCreateRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -371,3 +511,33 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
|
|||||||
export async function deleteRelyingParty(id: string) {
|
export async function deleteRelyingParty(id: string) {
|
||||||
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RPOwner = {
|
||||||
|
subject: string;
|
||||||
|
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRPOwner(clientId: string, subject: string) {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
|
if (error.response?.status === 401) {
|
||||||
|
window.localStorage.removeItem("admin_session");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ function detectLocale(): Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import enRaw from "../../../locales/en.toml?raw";
|
import enRaw from "../locales/en.toml?raw";
|
||||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import koRaw from "../../../locales/ko.toml?raw";
|
import koRaw from "../locales/ko.toml?raw";
|
||||||
|
|
||||||
const translations: Record<Locale, TomlObject> = {
|
const translations: Record<Locale, TomlObject> = {
|
||||||
ko: parseToml(koRaw),
|
ko: parseToml(koRaw),
|
||||||
|
|||||||
1316
adminfront/src/locales/en.toml
Normal file
1316
adminfront/src/locales/en.toml
Normal file
File diff suppressed because one or more lines are too long
1316
adminfront/src/locales/ko.toml
Normal file
1316
adminfront/src/locales/ko.toml
Normal file
File diff suppressed because one or more lines are too long
1316
adminfront/src/locales/template.toml
Normal file
1316
adminfront/src/locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { defineConfig } from "vite";
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
envPrefix: ["VITE_", "USERFRONT_"],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ func main() {
|
|||||||
|
|
||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
|
tenantGroupRepo := repository.NewTenantGroupRepository(db)
|
||||||
tenantService := service.NewTenantService(tenantRepo)
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
|
tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
||||||
@@ -254,14 +256,16 @@ func main() {
|
|||||||
secretRepo := repository.NewClientSecretRepository(db)
|
secretRepo := repository.NewClientSecretRepository(db)
|
||||||
consentRepo := repository.NewClientConsentRepository(db)
|
consentRepo := repository.NewClientConsentRepository(db)
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
|
||||||
adminHandler := handler.NewAdminHandler()
|
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
|
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
oryAdminProvider := service.NewOryProvider()
|
||||||
|
|
||||||
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
||||||
|
adminHandler := handler.NewAdminHandler(ketoService)
|
||||||
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
||||||
|
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
|
||||||
|
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService)
|
||||||
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
@@ -556,6 +560,7 @@ func main() {
|
|||||||
|
|
||||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||||
|
admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission)
|
||||||
|
|
||||||
// Tenant Management (Super Admin Only)
|
// Tenant Management (Super Admin Only)
|
||||||
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
|
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
|
||||||
@@ -564,9 +569,27 @@ func main() {
|
|||||||
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||||
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||||
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
||||||
|
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
|
||||||
|
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
||||||
|
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
||||||
|
|
||||||
|
// Tenant Group Management (Super Admin Only)
|
||||||
|
admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups)
|
||||||
|
admin.Post("/tenant-groups", requireSuperAdmin, tenantGroupHandler.CreateGroup)
|
||||||
|
admin.Get("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.GetGroup)
|
||||||
|
admin.Put("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.UpdateGroup)
|
||||||
|
admin.Delete("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.DeleteGroup)
|
||||||
|
admin.Post("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.AddTenantToGroup)
|
||||||
|
admin.Delete("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.RemoveTenantFromGroup)
|
||||||
|
admin.Get("/tenant-groups/:id/admins", requireSuperAdmin, tenantGroupHandler.ListAdmins)
|
||||||
|
admin.Post("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.AddAdmin)
|
||||||
|
admin.Delete("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.RemoveAdmin)
|
||||||
|
|
||||||
// Relying Party Management (Global List)
|
// Relying Party Management (Global List)
|
||||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||||
|
admin.Get("/relying-parties/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.ListOwners)
|
||||||
|
admin.Post("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.AddOwner)
|
||||||
|
admin.Delete("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.RemoveOwner)
|
||||||
|
|
||||||
// Relying Party Management (Tenant Context)
|
// Relying Party Management (Tenant Context)
|
||||||
admin.Post("/tenants/:tenantId/relying-parties",
|
admin.Post("/tenants/:tenantId/relying-parties",
|
||||||
@@ -607,14 +630,24 @@ func main() {
|
|||||||
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
||||||
|
|
||||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||||
dev := api.Group("/dev")
|
dev := api.Group("/dev", requireAdmin)
|
||||||
dev.Get("/clients", devHandler.ListClients)
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
dev.Post("/clients", devHandler.CreateClient)
|
dev.Post("/clients", devHandler.CreateClient)
|
||||||
dev.Get("/clients/:id", devHandler.GetClient)
|
dev.Get("/clients/:id",
|
||||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
|
||||||
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
|
devHandler.GetClient)
|
||||||
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
dev.Put("/clients/:id",
|
||||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.UpdateClient)
|
||||||
|
dev.Post("/clients/:id/secret/rotate",
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.RotateClientSecret)
|
||||||
|
dev.Patch("/clients/:id/status",
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.UpdateClientStatus)
|
||||||
|
dev.Delete("/clients/:id",
|
||||||
|
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
|
||||||
|
devHandler.DeleteClient)
|
||||||
dev.Get("/consents", devHandler.ListConsents)
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
slog.Info("[Bootstrap] Migrating database schemas...")
|
slog.Info("[Bootstrap] Migrating database schemas...")
|
||||||
// Add all domain models here
|
// Add all domain models here
|
||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
|
&domain.TenantGroup{},
|
||||||
&domain.Tenant{},
|
&domain.Tenant{},
|
||||||
&domain.TenantDomain{},
|
&domain.TenantDomain{},
|
||||||
&domain.User{},
|
&domain.User{},
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
|
|||||||
if t.ParentID != nil {
|
if t.ParentID != nil {
|
||||||
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
|
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
|
||||||
}
|
}
|
||||||
|
if t.TenantGroupID != nil {
|
||||||
|
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent_group", *t.TenantGroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.1 Sync Tenant Groups (Group Admins)
|
||||||
|
var groups []domain.TenantGroup
|
||||||
|
if err := db.Find(&groups).Error; err == nil {
|
||||||
|
slog.Info("Syncing tenant groups to Keto", "count", len(groups))
|
||||||
|
for range groups {
|
||||||
|
// 그룹 관리자 개념 확정 후 관계 생성 로직 추가 예정
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Sync All Users
|
// 2. Sync All Users
|
||||||
|
|||||||
@@ -68,18 +68,19 @@ type SignupRequest struct {
|
|||||||
// User Profile Models
|
// User Profile Models
|
||||||
|
|
||||||
type UserProfileResponse struct {
|
type UserProfileResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"` // 추가
|
Role string `json:"role"` // 추가
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
Tenant *Tenant `json:"tenant,omitempty"`
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
|
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
|
|||||||
@@ -17,23 +17,47 @@ const (
|
|||||||
|
|
||||||
// Tenant represents a tenant model stored in PostgreSQL.
|
// Tenant represents a tenant model stored in PostgreSQL.
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
||||||
Name string `gorm:"not null" json:"name"`
|
TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"`
|
||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
|
||||||
Description string `json:"description"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Status string `gorm:"default:'pending'" json:"status"`
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
Description string `json:"description"`
|
||||||
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
Status string `gorm:"default:'pending'" json:"status"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tenant) IsActive() bool {
|
func (t *Tenant) IsActive() bool {
|
||||||
return t.Status == TenantStatusActive
|
return t.Status == TenantStatusActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMergedConfig merges the group-level config with tenant-level config.
|
||||||
|
// Tenant config takes precedence.
|
||||||
|
func (t *Tenant) GetMergedConfig() JSONMap {
|
||||||
|
merged := make(JSONMap)
|
||||||
|
|
||||||
|
// 1. Apply Group Config (Base)
|
||||||
|
if t.TenantGroup != nil && t.TenantGroup.Config != nil {
|
||||||
|
for k, v := range t.TenantGroup.Config {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Apply Tenant Config (Overrides)
|
||||||
|
if t.Config != nil {
|
||||||
|
for k, v := range t.Config {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
// BeforeCreate hook to generate UUID if not present.
|
// BeforeCreate hook to generate UUID if not present.
|
||||||
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
if t.ID == "" {
|
if t.ID == "" {
|
||||||
|
|||||||
32
backend/internal/domain/tenant_group.go
Normal file
32
backend/internal/domain/tenant_group.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantGroup represents a collection of tenants.
|
||||||
|
type TenantGroup struct {
|
||||||
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Tenants []Tenant `gorm:"foreignKey:TenantGroupID" json:"tenants,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 (tg *TenantGroup) TableName() string {
|
||||||
|
return "tenant_groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tg *TenantGroup) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
|
if tg.ID == "" {
|
||||||
|
tg.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -1,22 +1,51 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdminHandler struct{}
|
type AdminHandler struct {
|
||||||
|
Keto service.KetoService
|
||||||
|
}
|
||||||
|
|
||||||
func NewAdminHandler() *AdminHandler {
|
func NewAdminHandler(keto service.KetoService) *AdminHandler {
|
||||||
return &AdminHandler{}
|
return &AdminHandler{Keto: keto}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CheckPermission(c *fiber.Ctx) error {
|
||||||
|
namespace := c.Query("namespace")
|
||||||
|
object := c.Query("object")
|
||||||
|
relation := c.Query("relation")
|
||||||
|
subject := c.Query("subject")
|
||||||
|
|
||||||
|
if namespace == "" || object == "" || relation == "" || subject == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "namespace, object, relation, and subject are required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"allowed": allowed,
|
||||||
|
"query": fiber.Map{
|
||||||
|
"namespace": namespace,
|
||||||
|
"object": object,
|
||||||
|
"relation": relation,
|
||||||
|
"subject": subject,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetSystemStats returns runtime statistics for monitoring
|
// GetSystemStats returns runtime statistics for monitoring
|
||||||
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||||
var m runtime.MemStats
|
var m runtime.MemStats
|
||||||
|
|||||||
@@ -125,10 +125,11 @@ func GenerateSecureAlnumToken(length int) string {
|
|||||||
|
|
||||||
func GenerateUserCode() string {
|
func GenerateUserCode() string {
|
||||||
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
return fmt.Sprintf("%c%c-%03d",
|
// [Fixed] 요청하신 포맷 (영문 2자리 + 숫자 6자리, 하이픈 없음)으로 변경
|
||||||
|
return fmt.Sprintf("%c%c%06d",
|
||||||
letters[rand.Intn(len(letters))],
|
letters[rand.Intn(len(letters))],
|
||||||
letters[rand.Intn(len(letters))],
|
letters[rand.Intn(len(letters))],
|
||||||
rand.Intn(1000),
|
rand.Intn(1000000),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,13 +959,20 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
|
|
||||||
userCode := GenerateUserCode()
|
userCode := GenerateUserCode()
|
||||||
token := GenerateSecureToken(3)
|
token := GenerateSecureToken(3)
|
||||||
pendingRef := GenerateSecureToken(3)
|
pendingRef := GenerateSecureToken(3)
|
||||||
|
|
||||||
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
|
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
|
||||||
|
|
||||||
|
// [Added] 사용자가 입력할 간편 코드를 Redis에 저장합니다. (이게 없으면 인증이 안 됩니다)
|
||||||
|
shortCodePayload, _ := json.Marshal(shortLoginCodePayload{
|
||||||
|
LoginID: lookupLoginID,
|
||||||
|
Code: token,
|
||||||
|
PendingRef: pendingRef,
|
||||||
|
})
|
||||||
|
h.RedisService.Set(prefixLoginCodeShort+userCode, string(shortCodePayload), defaultExpiration)
|
||||||
|
|
||||||
// Store in Redis
|
// Store in Redis
|
||||||
sessionData, _ := json.Marshal(map[string]string{
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
"status": statusPending,
|
"status": statusPending,
|
||||||
@@ -1018,12 +1026,13 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Send SMS
|
// Send SMS
|
||||||
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode)
|
phone := sanitizePhoneForSms(loginID)
|
||||||
|
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 간편 코드: %s", link, userCode)
|
||||||
if drySend {
|
if drySend {
|
||||||
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content)
|
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", phone, "content", content)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
|
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "to", phone)
|
||||||
if err := h.SmsService.SendSms(loginID, content); err != nil {
|
if err := h.SmsService.SendSms(phone, content); err != nil {
|
||||||
slog.Error("[Enchanted] SMS Failed", "error", err)
|
slog.Error("[Enchanted] SMS Failed", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
||||||
}
|
}
|
||||||
@@ -1585,12 +1594,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
|||||||
// --- OIDC 로그인 흐름 처리 끝 ---
|
// --- OIDC 로그인 흐름 처리 끝 ---
|
||||||
|
|
||||||
resp := fiber.Map{
|
resp := fiber.Map{
|
||||||
"sessionJwt": authInfo.SessionToken.JWT,
|
"sessionToken": authInfo.SessionToken.JWT,
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"provider": h.IdpProvider.Name(),
|
"provider": h.IdpProvider.Name(),
|
||||||
}
|
}
|
||||||
if authInfo.RefreshToken != nil {
|
if authInfo.RefreshToken != nil {
|
||||||
resp["refreshJwt"] = authInfo.RefreshToken.JWT
|
resp["refreshToken"] = authInfo.RefreshToken.JWT
|
||||||
}
|
}
|
||||||
if authInfo.Subject != "" {
|
if authInfo.Subject != "" {
|
||||||
resp["subject"] = authInfo.Subject
|
resp["subject"] = authInfo.Subject
|
||||||
@@ -2066,6 +2075,16 @@ type kratosCourierRequest struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizePhoneForSms - 네이버 SMS 등 국내 발송기를 위해 +82 형식을 010 형식으로 변환합니다.
|
||||||
|
func sanitizePhoneForSms(phone string) string {
|
||||||
|
p := strings.ReplaceAll(phone, "-", "")
|
||||||
|
p = strings.ReplaceAll(p, " ", "")
|
||||||
|
if strings.HasPrefix(p, "+82") {
|
||||||
|
return "0" + p[3:]
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
|
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
|
||||||
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
||||||
var req kratosCourierRequest
|
var req kratosCourierRequest
|
||||||
@@ -2444,16 +2463,6 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizePhoneForSms(phone string) string {
|
|
||||||
sanitized := strings.TrimSpace(phone)
|
|
||||||
if strings.HasPrefix(sanitized, "+82") {
|
|
||||||
sanitized = "0" + sanitized[3:]
|
|
||||||
}
|
|
||||||
sanitized = strings.ReplaceAll(sanitized, "-", "")
|
|
||||||
sanitized = strings.ReplaceAll(sanitized, " ", "")
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- User Profile Handlers ---
|
// --- User Profile Handlers ---
|
||||||
|
|
||||||
func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
|
func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
|
||||||
@@ -3944,6 +3953,13 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch Manageable Tenants for Admins
|
||||||
|
if profile.Role == domain.RoleSuperAdmin || profile.Role == domain.RoleTenantAdmin || profile.Role == domain.RoleRPAdmin {
|
||||||
|
if tenants, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID); err == nil {
|
||||||
|
profile.ManageableTenants = tenants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Save to Redis Cache (Short TTL)
|
// 4. Save to Redis Cache (Short TTL)
|
||||||
if h.RedisService != nil && cacheKey != "" {
|
if h.RedisService != nil && cacheKey != "" {
|
||||||
if data, err := json.Marshal(profile); err == nil {
|
if data, err := json.Marshal(profile); err == nil {
|
||||||
@@ -4773,10 +4789,7 @@ func extractLoginIDFromClaims(claims map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
|
||||||
if kratosURL == "" {
|
|
||||||
kratosURL = "http://kratos:4433"
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
@@ -4807,10 +4820,7 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
||||||
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
|
||||||
if kratosURL == "" {
|
|
||||||
kratosURL = "http://kratos:4433"
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -4833,6 +4843,7 @@ func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.ID, nil
|
return result.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4841,10 +4852,7 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
|
|||||||
return "", fmt.Errorf("kratos identity id is empty")
|
return "", fmt.Errorf("kratos identity id is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
kratosAdminURL := strings.TrimRight(utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"), "/")
|
||||||
if kratosAdminURL == "" {
|
|
||||||
kratosAdminURL = "http://kratos:4434"
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"identity_id": identityID,
|
"identity_id": identityID,
|
||||||
|
|||||||
@@ -288,8 +288,8 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var got map[string]string
|
var got map[string]string
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
if got["sessionJwt"] != "valid-jwt" {
|
if got["sessionToken"] != "valid-jwt" {
|
||||||
t.Errorf("expected jwt valid-jwt, got %s", got["sessionJwt"])
|
t.Errorf("expected jwt valid-jwt, got %s", got["sessionToken"])
|
||||||
}
|
}
|
||||||
// No redirectTo
|
// No redirectTo
|
||||||
if _, ok := got["redirectTo"]; ok {
|
if _, ok := got["redirectTo"]; ok {
|
||||||
|
|||||||
@@ -22,15 +22,17 @@ type DevHandler struct {
|
|||||||
SecretRepo domain.ClientSecretRepository
|
SecretRepo domain.ClientSecretRepository
|
||||||
KratosAdmin *service.KratosAdminService
|
KratosAdmin *service.KratosAdminService
|
||||||
ConsentRepo repository.ClientConsentRepository
|
ConsentRepo repository.ClientConsentRepository
|
||||||
|
RPService service.RelyingPartyService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler {
|
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpService service.RelyingPartyService) *DevHandler {
|
||||||
return &DevHandler{
|
return &DevHandler{
|
||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
Redis: redis,
|
Redis: redis,
|
||||||
SecretRepo: secretRepo,
|
SecretRepo: secretRepo,
|
||||||
KratosAdmin: service.NewKratosAdminService(),
|
KratosAdmin: service.NewKratosAdminService(),
|
||||||
ConsentRepo: consentRepo,
|
ConsentRepo: consentRepo,
|
||||||
|
RPService: rpService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,38 +97,58 @@ type clientUpsertRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||||
limit := c.QueryInt("limit", 50)
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
offset := c.QueryInt("offset", 0)
|
if !ok {
|
||||||
if limit <= 0 {
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found"})
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
// Super Admin sees all (best effort via Hydra list for now, or we can use RPService if it's improved)
|
||||||
|
if profile.Role == domain.RoleSuperAdmin {
|
||||||
|
limit := c.QueryInt("limit", 50)
|
||||||
|
offset := c.QueryInt("offset", 0)
|
||||||
|
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
items := make([]clientSummary, 0, len(clients))
|
||||||
|
for _, client := range clients {
|
||||||
|
items = append(items, h.mapClientSummary(client))
|
||||||
|
}
|
||||||
|
return c.JSON(clientListResponse{Items: items, Limit: limit, Offset: offset})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For others, only show manageable tenants' clients
|
||||||
|
var tenantIDs []string
|
||||||
|
for _, t := range profile.ManageableTenants {
|
||||||
|
tenantIDs = append(tenantIDs, t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tenantIDs) == 0 && profile.TenantID != nil {
|
||||||
|
tenantIDs = append(tenantIDs, *profile.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tenantIDs) == 0 {
|
||||||
|
return c.JSON(clientListResponse{Items: []clientSummary{}, Limit: 50, Offset: 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
rps, err := h.RPService.ListByTenantIDs(c.Context(), tenantIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"})
|
|
||||||
}
|
|
||||||
errMsg := err.Error()
|
|
||||||
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
|
||||||
"error": "Hydra service is unavailable. Please check if Ory Hydra is running.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]clientSummary, 0, len(clients))
|
items := make([]clientSummary, 0, len(rps))
|
||||||
for _, client := range clients {
|
for _, rp := range rps {
|
||||||
items = append(items, h.mapClientSummary(client))
|
// We need HydraClient details for the summary
|
||||||
|
client, err := h.Hydra.GetClient(c.Context(), rp.ClientID)
|
||||||
|
if err == nil {
|
||||||
|
items = append(items, h.mapClientSummary(*client))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(clientListResponse{
|
return c.JSON(clientListResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
Limit: limit,
|
Limit: len(items),
|
||||||
Offset: offset,
|
Offset: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +166,11 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set for audit logging
|
||||||
|
if tid, ok := client.Metadata["tenant_id"].(string); ok {
|
||||||
|
c.Locals("tenant_id", tid)
|
||||||
|
}
|
||||||
|
|
||||||
summary := h.mapClientSummary(*client)
|
summary := h.mapClientSummary(*client)
|
||||||
return c.JSON(clientDetailResponse{
|
return c.JSON(clientDetailResponse{
|
||||||
Client: summary,
|
Client: summary,
|
||||||
@@ -197,11 +224,49 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
var req clientUpsertRequest
|
var req clientUpsertRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine Tenant ID
|
||||||
|
targetTenantID := c.Get("X-Tenant-ID")
|
||||||
|
if targetTenantID == "" && profile.TenantID != nil {
|
||||||
|
targetTenantID = *profile.TenantID
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetTenantID == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set for audit logging
|
||||||
|
c.Locals("tenant_id", targetTenantID)
|
||||||
|
|
||||||
|
// Validate Permission
|
||||||
|
isAllowed := false
|
||||||
|
if profile.Role == domain.RoleSuperAdmin {
|
||||||
|
isAllowed = true
|
||||||
|
} else {
|
||||||
|
for _, t := range profile.ManageableTenants {
|
||||||
|
if t.ID == targetTenantID {
|
||||||
|
isAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isAllowed && profile.TenantID != nil && *profile.TenantID == targetTenantID {
|
||||||
|
isAllowed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isAllowed {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "you do not have permission to create clients for this tenant"})
|
||||||
|
}
|
||||||
|
|
||||||
clientID := strings.TrimSpace(valueOr(req.ID, ""))
|
clientID := strings.TrimSpace(valueOr(req.ID, ""))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = uuid.NewString()
|
clientID = uuid.NewString()
|
||||||
@@ -257,11 +322,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := h.Hydra.CreateClient(c.Context(), clientReq)
|
// Use RPService to ensure Keto relations are created
|
||||||
|
rp, err := h.RPService.Create(c.Context(), targetTenantID, clientReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch back the Hydra client to get the secret (RPService.Create returns domain.RelyingParty which has limited fields)
|
||||||
|
created, err := h.Hydra.GetClient(c.Context(), rp.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "client created but failed to retrieve details"})
|
||||||
|
}
|
||||||
|
|
||||||
// Store secret in metadata for later retrieval
|
// Store secret in metadata for later retrieval
|
||||||
if created.ClientSecret != "" {
|
if created.ClientSecret != "" {
|
||||||
// 1. Store in PostgreSQL (Source of Truth)
|
// 1. Store in PostgreSQL (Source of Truth)
|
||||||
@@ -307,6 +379,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set for audit logging
|
||||||
|
if tid, ok := current.Metadata["tenant_id"].(string); ok {
|
||||||
|
c.Locals("tenant_id", tid)
|
||||||
|
}
|
||||||
|
|
||||||
clientType := ""
|
clientType := ""
|
||||||
if req.Type != nil {
|
if req.Type != nil {
|
||||||
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
||||||
@@ -382,6 +459,14 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch first for audit log tenant_id
|
||||||
|
client, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||||
|
if err == nil {
|
||||||
|
if tid, ok := client.Metadata["tenant_id"].(string); ok {
|
||||||
|
c.Locals("tenant_id", tid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
|
||||||
if errors.Is(err, service.ErrHydraNotFound) {
|
if errors.Is(err, service.ErrHydraNotFound) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
|
||||||
@@ -403,11 +488,24 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permission Check
|
||||||
|
if profile.Role != domain.RoleSuperAdmin {
|
||||||
|
allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "view")
|
||||||
|
if err != nil || !allowed {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to view consents for this client"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
@@ -484,12 +582,28 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||||
|
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
|
}
|
||||||
|
|
||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
|
||||||
}
|
}
|
||||||
clientID := strings.TrimSpace(c.Query("client_id"))
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
||||||
|
|
||||||
|
// Permission Check (if clientID is provided)
|
||||||
|
if clientID != "" && profile.Role != domain.RoleSuperAdmin {
|
||||||
|
allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "manage")
|
||||||
|
if err != nil || !allowed {
|
||||||
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to revoke consents for this client"})
|
||||||
|
}
|
||||||
|
} else if clientID == "" && profile.Role != domain.RoleSuperAdmin {
|
||||||
|
// If clientID is not provided, we might need a more global check or just disallow it for non-superadmins
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required for non-superadmins"})
|
||||||
|
}
|
||||||
|
|
||||||
// If subject is not a UUID, try to resolve it as an identifier (email/username)
|
// If subject is not a UUID, try to resolve it as an identifier (email/username)
|
||||||
if _, err := uuid.Parse(subject); err != nil {
|
if _, err := uuid.Parse(subject); err != nil {
|
||||||
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
|
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
|
||||||
@@ -532,6 +646,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set for audit logging
|
||||||
|
if tid, ok := current.Metadata["tenant_id"].(string); ok {
|
||||||
|
c.Locals("tenant_id", tid)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Update Hydra
|
// 3. Update Hydra
|
||||||
current.ClientSecret = newSecret
|
current.ClientSecret = newSecret
|
||||||
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -10,8 +12,75 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MockRPService struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRPService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withMockProfile(profile *domain.UserProfileResponse) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", profile)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListClients_Success(t *testing.T) {
|
func TestListClients_Success(t *testing.T) {
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.URL.Path == "/clients" {
|
if r.URL.Path == "/clients" {
|
||||||
@@ -30,7 +99,11 @@ func TestListClients_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/api/v1/dev/clients", h.ListClients)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Get("/api/v1/dev/clients", withMockProfile(adminProfile), h.ListClients)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -66,7 +139,11 @@ func TestGetClient_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -92,7 +169,11 @@ func TestGetClient_NotFound(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
@@ -109,30 +190,49 @@ func TestCreateClient_Success(t *testing.T) {
|
|||||||
"client_secret": "secret-123",
|
"client_secret": "secret-123",
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/new-client-123" {
|
||||||
|
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
|
||||||
|
"client_id": "new-client-123",
|
||||||
|
"client_name": "New App",
|
||||||
|
"client_secret": "secret-123",
|
||||||
|
"metadata": map[string]interface{}{"status": "active"},
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error path: " + r.URL.Path}), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
|
||||||
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
redisRepo := &mockRedisRepo{data: make(map[string]string)}
|
||||||
|
mockRP := new(MockRPService)
|
||||||
|
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
|
PublicURL: "http://hydra-public.test",
|
||||||
HTTPClient: &http.Client{Transport: transport},
|
HTTPClient: &http.Client{Transport: transport},
|
||||||
},
|
},
|
||||||
SecretRepo: secretRepo,
|
SecretRepo: secretRepo,
|
||||||
Redis: redisRepo,
|
Redis: redisRepo,
|
||||||
|
RPService: mockRP,
|
||||||
}
|
}
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
adminProfile := &domain.UserProfileResponse{
|
||||||
|
ID: "admin-1",
|
||||||
|
Role: domain.RoleSuperAdmin,
|
||||||
|
}
|
||||||
|
app.Post("/api/v1/dev/clients", withMockProfile(adminProfile), h.CreateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
"client_name": "New App",
|
"client_name": "New App",
|
||||||
"type": "confidential",
|
"type": "confidential",
|
||||||
"redirectUris": []string{"http://localhost/cb"},
|
"redirectUris": []string{"http://localhost/cb"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mockRP.On("Create", mock.Anything, "t1", mock.Anything).Return(&domain.RelyingParty{ClientID: "new-client-123"}, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Tenant-ID", "t1")
|
||||||
|
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
|
|
||||||
type RelyingPartyHandler struct {
|
type RelyingPartyHandler struct {
|
||||||
Service service.RelyingPartyService
|
Service service.RelyingPartyService
|
||||||
|
UserSvc *service.KratosAdminService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler {
|
func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler {
|
||||||
return &RelyingPartyHandler{Service: s}
|
return &RelyingPartyHandler{Service: s, UserSvc: userSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
|
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
|
||||||
@@ -110,3 +111,58 @@ func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *RelyingPartyHandler) ListOwners(c *fiber.Ctx) error {
|
||||||
|
clientID := c.Params("id")
|
||||||
|
subjects, err := h.Service.ListOwners(c.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ownerInfo struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Type string `json:"type"` // "user" or "group"
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := make([]ownerInfo, 0, len(subjects))
|
||||||
|
for _, s := range subjects {
|
||||||
|
info := ownerInfo{Subject: s, Type: "unknown"}
|
||||||
|
if len(s) > 5 && s[:5] == "User:" {
|
||||||
|
info.Type = "user"
|
||||||
|
userID := s[5:]
|
||||||
|
identity, err := h.UserSvc.GetIdentity(c.Context(), userID)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
info.Name, _ = identity.Traits["name"].(string)
|
||||||
|
info.Email, _ = identity.Traits["email"].(string)
|
||||||
|
}
|
||||||
|
} else if len(s) > 10 && s[:10] == "UserGroup:" {
|
||||||
|
info.Type = "group"
|
||||||
|
// Group name enrichment could be added if we have a GroupService here
|
||||||
|
}
|
||||||
|
owners = append(owners, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(owners)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RelyingPartyHandler) AddOwner(c *fiber.Ctx) error {
|
||||||
|
clientID := c.Params("id")
|
||||||
|
subject := c.Params("subject") // e.g. "User:uuid"
|
||||||
|
|
||||||
|
if err := h.Service.AddOwner(c.Context(), clientID, subject); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "owner added"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RelyingPartyHandler) RemoveOwner(c *fiber.Ctx) error {
|
||||||
|
clientID := c.Params("id")
|
||||||
|
subject := c.Params("subject")
|
||||||
|
|
||||||
|
if err := h.Service.RemoveOwner(c.Context(), clientID, subject); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "owner removed"})
|
||||||
|
}
|
||||||
|
|||||||
193
backend/internal/handler/tenant_group_handler.go
Normal file
193
backend/internal/handler/tenant_group_handler.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TenantGroupHandler struct {
|
||||||
|
Service service.TenantGroupService
|
||||||
|
UserService *service.KratosAdminService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
|
||||||
|
return &TenantGroupHandler{Service: svc, UserService: userSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantGroupSummary struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {
|
||||||
|
limit := c.QueryInt("limit", 50)
|
||||||
|
offset := c.QueryInt("offset", 0)
|
||||||
|
|
||||||
|
groups, total, err := h.Service.ListGroups(c.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]tenantGroupSummary, 0, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
items = append(items, mapTenantGroupSummary(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) GetGroup(c *fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
group, err := h.Service.GetGroup(c.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(mapTenantGroupSummary(*group))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) CreateGroup(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := h.Service.CreateGroup(c.Context(), req.Name, req.Slug, req.Description)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(mapTenantGroupSummary(*group))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) UpdateGroup(c *fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := h.Service.UpdateGroup(c.Context(), id, req.Name, req.Description)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(mapTenantGroupSummary(*group))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) DeleteGroup(c *fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
if err := h.Service.DeleteGroup(c.Context(), id); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) AddTenantToGroup(c *fiber.Ctx) error {
|
||||||
|
groupID := c.Params("id")
|
||||||
|
tenantID := c.Params("tenantId")
|
||||||
|
|
||||||
|
if err := h.Service.AddTenantToGroup(c.Context(), groupID, tenantID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "tenant added to group"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) error {
|
||||||
|
groupID := c.Params("id")
|
||||||
|
tenantID := c.Params("tenantId")
|
||||||
|
|
||||||
|
if err := h.Service.RemoveTenantFromGroup(c.Context(), groupID, tenantID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "tenant removed from group"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) ListAdmins(c *fiber.Ctx) error {
|
||||||
|
groupID := c.Params("id")
|
||||||
|
userIDs, err := h.Service.ListGroupAdmins(c.Context(), groupID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
admins := make([]adminInfo, 0, len(userIDs))
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
identity, err := h.UserService.GetIdentity(c.Context(), uid)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
name, _ := identity.Traits["name"].(string)
|
||||||
|
email, _ := identity.Traits["email"].(string)
|
||||||
|
admins = append(admins, adminInfo{
|
||||||
|
ID: uid,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fallback if identity not found in Kratos
|
||||||
|
admins = append(admins, adminInfo{ID: uid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(admins)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) AddAdmin(c *fiber.Ctx) error {
|
||||||
|
groupID := c.Params("id")
|
||||||
|
userID := c.Params("userId")
|
||||||
|
|
||||||
|
if err := h.Service.AddGroupAdmin(c.Context(), groupID, userID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "admin added to group"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantGroupHandler) RemoveAdmin(c *fiber.Ctx) error {
|
||||||
|
groupID := c.Params("id")
|
||||||
|
userID := c.Params("userId")
|
||||||
|
|
||||||
|
if err := h.Service.RemoveGroupAdmin(c.Context(), groupID, userID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "admin removed from group"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapTenantGroupSummary(g domain.TenantGroup) tenantGroupSummary {
|
||||||
|
tenants := make([]tenantSummary, 0, len(g.Tenants))
|
||||||
|
for _, t := range g.Tenants {
|
||||||
|
tenants = append(tenants, mapTenantSummary(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenantGroupSummary{
|
||||||
|
ID: g.ID,
|
||||||
|
Name: g.Name,
|
||||||
|
Slug: g.Slug,
|
||||||
|
Description: g.Description,
|
||||||
|
Tenants: tenants,
|
||||||
|
Config: g.Config,
|
||||||
|
CreatedAt: g.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: g.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,22 +14,25 @@ import (
|
|||||||
type TenantHandler struct {
|
type TenantHandler struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Service service.TenantService
|
Service service.TenantService
|
||||||
|
Keto service.KetoService
|
||||||
|
UserSvc *service.KratosAdminService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler {
|
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler {
|
||||||
return &TenantHandler{DB: db, Service: svc}
|
return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantSummary struct {
|
type tenantSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Domains []string `json:"domains,omitempty"`
|
TenantGroupID *string `json:"tenantGroupId,omitempty"`
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
Config domain.JSONMap `json:"config,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantListResponse struct {
|
type tenantListResponse struct {
|
||||||
@@ -100,7 +103,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tenants []domain.Tenant
|
var tenants []domain.Tenant
|
||||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +126,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
if err := h.DB.Preload("Domains").Preload("TenantGroup").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
||||||
}
|
}
|
||||||
@@ -204,12 +207,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Slug *string `json:"slug"`
|
Slug *string `json:"slug"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
Domains []string `json:"domains"`
|
TenantGroupID *string `json:"tenantGroupId"`
|
||||||
Config map[string]any `json:"config"`
|
Domains []string `json:"domains"`
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||||
@@ -251,6 +255,29 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
tenant.Config = req.Config
|
tenant.Config = req.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Group Change
|
||||||
|
if req.TenantGroupID != nil {
|
||||||
|
oldGroupID := tenant.TenantGroupID
|
||||||
|
newGroupID := req.TenantGroupID
|
||||||
|
if *newGroupID == "" {
|
||||||
|
newGroupID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Keto if group changed
|
||||||
|
if h.Keto != nil {
|
||||||
|
// Remove old group relation if existed
|
||||||
|
if oldGroupID != nil && (newGroupID == nil || *oldGroupID != *newGroupID) {
|
||||||
|
_ = h.Keto.DeleteRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *oldGroupID)
|
||||||
|
}
|
||||||
|
// Add new group relation
|
||||||
|
if newGroupID != nil && (oldGroupID == nil || *oldGroupID != *newGroupID) {
|
||||||
|
_ = h.Keto.CreateRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *newGroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant.TenantGroupID = newGroupID
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -301,6 +328,58 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
userIDs, err := h.Service.ListTenantAdmins(c.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
admins := make([]adminInfo, 0, len(userIDs))
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
identity, err := h.UserSvc.GetIdentity(c.Context(), uid)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
name, _ := identity.Traits["name"].(string)
|
||||||
|
email, _ := identity.Traits["email"].(string)
|
||||||
|
admins = append(admins, adminInfo{
|
||||||
|
ID: uid,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
admins = append(admins, adminInfo{ID: uid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(admins)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
userID := c.Params("userId")
|
||||||
|
|
||||||
|
if err := h.Service.AddTenantAdmin(c.Context(), tenantID, userID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "admin added to tenant"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
userID := c.Params("userId")
|
||||||
|
|
||||||
|
if err := h.Service.RemoveTenantAdmin(c.Context(), tenantID, userID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"message": "admin removed from tenant"})
|
||||||
|
}
|
||||||
|
|
||||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||||
domains := make([]string, 0, len(t.Domains))
|
domains := make([]string, 0, len(t.Domains))
|
||||||
for _, d := range t.Domains {
|
for _, d := range t.Domains {
|
||||||
@@ -308,15 +387,16 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return tenantSummary{
|
return tenantSummary{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Slug: t.Slug,
|
Slug: t.Slug,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
Domains: domains,
|
TenantGroupID: t.TenantGroupID,
|
||||||
Config: t.Config,
|
Domains: domains,
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
Config: t.GetMergedConfig(),
|
||||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,26 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) {
|
|||||||
m.Called(keto)
|
m.Called(keto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||||
|
args := m.Called(ctx, userID)
|
||||||
|
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
args := m.Called(ctx, tenantID, userID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
args := m.Called(ctx, tenantID, userID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
|
||||||
|
args := m.Called(ctx, tenantID)
|
||||||
|
return args.Get(0).([]string), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenantHandler_CreateTenant(t *testing.T) {
|
func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
|
|||||||
123
backend/internal/handler/tenant_rebac_handler_test.go
Normal file
123
backend/internal/handler/tenant_rebac_handler_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/middleware"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reusing MockKetoService from previous step or defining here if needed
|
||||||
|
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) ([]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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockAuthHandler implements middleware.AuthProfileProvider
|
||||||
|
type MockAuthHandler struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||||
|
args := m.Called(c)
|
||||||
|
return args.Get(0).(*domain.UserProfileResponse), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireKetoPermission_Tenant_AuditContext(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
mockAuth := new(MockAuthHandler)
|
||||||
|
|
||||||
|
config := middleware.RBACConfig{
|
||||||
|
AuthHandler: mockAuth,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-1"
|
||||||
|
tenantID := "tenant-abc"
|
||||||
|
|
||||||
|
// Mock user profile
|
||||||
|
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
||||||
|
ID: userID,
|
||||||
|
Role: domain.RoleTenantAdmin,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Mock Keto: Allow access
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "manage").Return(true, nil)
|
||||||
|
|
||||||
|
// Route with middleware
|
||||||
|
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "manage"), func(c *fiber.Ctx) error {
|
||||||
|
// Verify that tenant_id was injected into Locals for audit log
|
||||||
|
assert.Equal(t, tenantID, c.Locals("tenant_id"))
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockAuth.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireKetoPermission_Deny(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
mockAuth := new(MockAuthHandler)
|
||||||
|
|
||||||
|
config := middleware.RBACConfig{
|
||||||
|
AuthHandler: mockAuth,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-bad"
|
||||||
|
tenantID := "tenant-secret"
|
||||||
|
|
||||||
|
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
|
||||||
|
ID: userID,
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Mock Keto: Deny access
|
||||||
|
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "view").Return(false, nil)
|
||||||
|
|
||||||
|
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "view"), func(c *fiber.Ctx) error {
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set tenant_id for audit logging if namespace is Tenant
|
||||||
|
if namespace == "Tenant" {
|
||||||
|
c.Locals("tenant_id", objectID)
|
||||||
|
}
|
||||||
|
|
||||||
// Check with Keto
|
// Check with Keto
|
||||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||||
if err != nil || !allowed {
|
if err != nil || !allowed {
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
|
|||||||
return args.Get(0).([]service.RelationTuple), args.Error(1)
|
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.
|
// Fixed MockKetoService to match service.KetoService exactly if possible.
|
||||||
// Wait, middleware/rbac.go imports baron-sso-backend/internal/service.
|
// Wait, middleware/rbac.go imports baron-sso-backend/internal/service.
|
||||||
// So I should use service.RelationTuple.
|
// So I should use service.RelationTuple.
|
||||||
|
|||||||
65
backend/internal/repository/tenant_group_repository.go
Normal file
65
backend/internal/repository/tenant_group_repository.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TenantGroupRepository interface {
|
||||||
|
Create(ctx context.Context, group *domain.TenantGroup) error
|
||||||
|
Update(ctx context.Context, group *domain.TenantGroup) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
FindByID(ctx context.Context, id string) (*domain.TenantGroup, error)
|
||||||
|
List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
|
||||||
|
AddTenant(ctx context.Context, groupID, tenantID string) error
|
||||||
|
RemoveTenant(ctx context.Context, groupID, tenantID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantGroupRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTenantGroupRepository(db *gorm.DB) TenantGroupRepository {
|
||||||
|
return &tenantGroupRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantGroupRepository) Create(ctx context.Context, group *domain.TenantGroup) error {
|
||||||
|
return r.db.WithContext(ctx).Create(group).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantGroupRepository) Update(ctx context.Context, group *domain.TenantGroup) error {
|
||||||
|
return r.db.WithContext(ctx).Save(group).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantGroupRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&domain.TenantGroup{}, "id = ?", id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantGroupRepository) FindByID(ctx context.Context, id string) (*domain.TenantGroup, error) {
|
||||||
|
var group domain.TenantGroup
|
||||||
|
if err := r.db.WithContext(ctx).Preload("Tenants").First(&group, "id = ?", id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantGroupRepository) List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
|
||||||
|
var groups []domain.TenantGroup
|
||||||
|
var total int64
|
||||||
|
db := r.db.WithContext(ctx).Model(&domain.TenantGroup{})
|
||||||
|
db.Count(&total)
|
||||||
|
if err := db.Limit(limit).Offset(offset).Find(&groups).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return groups, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantGroupRepository) AddTenant(ctx context.Context, groupID, tenantID string) error {
|
||||||
|
return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ?", tenantID).Update("tenant_group_id", groupID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tenantGroupRepository) RemoveTenant(ctx context.Context, groupID, tenantID string) error {
|
||||||
|
return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ? AND tenant_group_id = ?", tenantID, groupID).Update("tenant_group_id", nil).Error
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type TenantRepository interface {
|
|||||||
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
FindByName(ctx context.Context, name string) (*domain.Tenant, error)
|
FindByName(ctx context.Context, name string) (*domain.Tenant, error)
|
||||||
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||||
|
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
|
||||||
AddDomain(ctx context.Context, tenantID string, domainName string) error
|
AddDomain(ctx context.Context, tenantID string, domainName string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,17 @@ func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Ten
|
|||||||
return &tenant, nil
|
return &tenant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
||||||
|
var tenants []domain.Tenant
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return tenants, nil
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tenants, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {
|
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -27,8 +28,8 @@ type HydraAdminService struct {
|
|||||||
|
|
||||||
func NewHydraAdminService() *HydraAdminService {
|
func NewHydraAdminService() *HydraAdminService {
|
||||||
return &HydraAdminService{
|
return &HydraAdminService{
|
||||||
AdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
|
AdminURL: utils.GetEnv("HYDRA_ADMIN_URL", "http://hydra:4445"),
|
||||||
PublicURL: getenv("HYDRA_PUBLIC_URL", "http://hydra:4444"),
|
PublicURL: utils.GetEnv("HYDRA_PUBLIC_URL", "http://hydra:4444"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -9,7 +10,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ type KetoService interface {
|
|||||||
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||||
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
|
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
|
||||||
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
|
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
|
||||||
|
ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ketoService struct {
|
type ketoService struct {
|
||||||
@@ -27,14 +28,8 @@ type ketoService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewKetoService() KetoService {
|
func NewKetoService() KetoService {
|
||||||
readURL := os.Getenv("KETO_READ_URL")
|
readURL := utils.GetEnv("KETO_READ_URL", "http://keto:4466")
|
||||||
if readURL == "" {
|
writeURL := utils.GetEnv("KETO_WRITE_URL", "http://keto:4467")
|
||||||
readURL = "http://keto:4466"
|
|
||||||
}
|
|
||||||
writeURL := os.Getenv("KETO_WRITE_URL")
|
|
||||||
if writeURL == "" {
|
|
||||||
writeURL = "http://keto:4467"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ketoService{
|
return &ketoService{
|
||||||
readURL: readURL,
|
readURL: readURL,
|
||||||
@@ -192,3 +187,40 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
|
|||||||
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||||
|
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL))
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("namespace", namespace)
|
||||||
|
q.Set("relation", relation)
|
||||||
|
q.Set("subject_id", subject)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res relationTuplesResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
objects := make([]string, 0, len(res.RelationTuples))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, rt := range res.RelationTuples {
|
||||||
|
if !seen[rt.Object] {
|
||||||
|
objects = append(objects, rt.Object)
|
||||||
|
seen[rt.Object] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -28,7 +29,7 @@ type KratosAdminService struct {
|
|||||||
|
|
||||||
func NewKratosAdminService() *KratosAdminService {
|
func NewKratosAdminService() *KratosAdminService {
|
||||||
return &KratosAdminService{
|
return &KratosAdminService{
|
||||||
AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
AdminURL: utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +228,9 @@ func (s *KratosAdminService) httpClient() *http.Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getenvKratos(key, fallback string) string {
|
func getenvKratos(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
v := os.Getenv(key)
|
||||||
return v
|
if v == "" {
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
return fallback
|
return strings.Trim(v, "\"")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -27,9 +28,9 @@ type OryProvider struct {
|
|||||||
|
|
||||||
func NewOryProvider() *OryProvider {
|
func NewOryProvider() *OryProvider {
|
||||||
return &OryProvider{
|
return &OryProvider{
|
||||||
KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
KratosAdminURL: utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||||
KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
KratosPublicURL: utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
||||||
HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
|
HydraAdminURL: utils.GetEnv("HYDRA_ADMIN_URL", "http://hydra:4445"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,10 +729,12 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getenv(key, fallback string) string {
|
func getenv(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
v := os.Getenv(key)
|
||||||
return v
|
if v == "" {
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
return fallback
|
// Strip surrounding double quotes if present
|
||||||
|
return strings.Trim(v, "\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
|
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type RelyingPartyService interface {
|
|||||||
ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error)
|
ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error)
|
||||||
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
|
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
|
||||||
Delete(ctx context.Context, clientID string) error
|
Delete(ctx context.Context, clientID string) error
|
||||||
|
CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error)
|
||||||
|
AddOwner(ctx context.Context, clientID, subject string) error
|
||||||
|
RemoveOwner(ctx context.Context, clientID, subject string) error
|
||||||
|
ListOwners(ctx context.Context, clientID string) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type relyingPartyService struct {
|
type relyingPartyService struct {
|
||||||
@@ -158,6 +162,31 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) {
|
||||||
|
return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *relyingPartyService) AddOwner(ctx context.Context, clientID, subject string) error {
|
||||||
|
return s.ketoService.CreateRelation(ctx, "RelyingParty", clientID, "owners", subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *relyingPartyService) RemoveOwner(ctx context.Context, clientID, subject string) error {
|
||||||
|
return s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "owners", subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *relyingPartyService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
|
||||||
|
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", clientID, "owners", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
subjects := make([]string, 0, len(tuples))
|
||||||
|
for _, t := range tuples {
|
||||||
|
subjects = append(subjects, t.SubjectID)
|
||||||
|
}
|
||||||
|
return subjects, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
|
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
|
|||||||
return args.Get(0).([]RelationTuple), args.Error(1)
|
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 ---
|
// --- Test Helpers ---
|
||||||
|
|
||||||
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)
|
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|||||||
130
backend/internal/service/tenant_group_service.go
Normal file
130
backend/internal/service/tenant_group_service.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/repository"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TenantGroupService interface {
|
||||||
|
CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error)
|
||||||
|
GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error)
|
||||||
|
ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
|
||||||
|
UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error)
|
||||||
|
DeleteGroup(ctx context.Context, id string) error
|
||||||
|
AddTenantToGroup(ctx context.Context, groupID, tenantID string) error
|
||||||
|
RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error
|
||||||
|
AddGroupAdmin(ctx context.Context, groupID, userID string) error
|
||||||
|
RemoveGroupAdmin(ctx context.Context, groupID, userID string) error
|
||||||
|
ListGroupAdmins(ctx context.Context, groupID string) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantGroupService struct {
|
||||||
|
repo repository.TenantGroupRepository
|
||||||
|
keto KetoService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTenantGroupService(repo repository.TenantGroupRepository, keto KetoService) TenantGroupService {
|
||||||
|
return &tenantGroupService{repo: repo, keto: keto}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error) {
|
||||||
|
group := &domain.TenantGroup{
|
||||||
|
Name: name,
|
||||||
|
Slug: slug,
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
if err := s.repo.Create(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error) {
|
||||||
|
return s.repo.FindByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
|
||||||
|
return s.repo.List(ctx, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error) {
|
||||||
|
group, err := s.repo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
group.Name = name
|
||||||
|
group.Description = description
|
||||||
|
if err := s.repo.Update(ctx, group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) DeleteGroup(ctx context.Context, id string) error {
|
||||||
|
return s.repo.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) AddTenantToGroup(ctx context.Context, groupID, tenantID string) error {
|
||||||
|
if err := s.repo.AddTenant(ctx, groupID, tenantID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Keto] ReBAC: Tenant -> Group membership
|
||||||
|
if s.keto != nil {
|
||||||
|
err := s.keto.CreateRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to sync Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error {
|
||||||
|
if err := s.repo.RemoveTenant(ctx, groupID, tenantID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Keto] ReBAC: Remove Tenant -> Group membership
|
||||||
|
if s.keto != nil {
|
||||||
|
err := s.keto.DeleteRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to remove Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) AddGroupAdmin(ctx context.Context, groupID, userID string) error {
|
||||||
|
if s.keto == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.keto.CreateRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) RemoveGroupAdmin(ctx context.Context, groupID, userID string) error {
|
||||||
|
if s.keto == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.keto.DeleteRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantGroupService) ListGroupAdmins(ctx context.Context, groupID string) ([]string, error) {
|
||||||
|
if s.keto == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
tuples, err := s.keto.ListRelations(ctx, "TenantGroup", groupID, "admins", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := make([]string, 0, len(tuples))
|
||||||
|
for _, t := range tuples {
|
||||||
|
// subject_id is "User:uuid"
|
||||||
|
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
|
||||||
|
userIDs = append(userIDs, t.SubjectID[5:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userIDs, nil
|
||||||
|
}
|
||||||
108
backend/internal/service/tenant_rebac_service_test.go
Normal file
108
backend/internal/service/tenant_rebac_service_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockTenantRepository is a mock implementation of repository.TenantRepository
|
||||||
|
type MockTenantRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) {
|
||||||
|
mockRepo := new(MockTenantRepository)
|
||||||
|
mockKeto := new(MockKetoService)
|
||||||
|
svc := &tenantService{
|
||||||
|
repo: mockRepo,
|
||||||
|
keto: mockKeto,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-123"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Mock direct tenant management (admins relation)
|
||||||
|
mockKeto.On("ListObjects", ctx, "Tenant", "admins", userID).Return([]string{"t-direct-1"}, nil)
|
||||||
|
|
||||||
|
// 2. Mock group management (admins of a group)
|
||||||
|
mockKeto.On("ListObjects", ctx, "TenantGroup", "admins", userID).Return([]string{"g-1"}, nil)
|
||||||
|
|
||||||
|
// 3. Mock tenants belonging to group g-1
|
||||||
|
mockKeto.On("ListRelations", ctx, "Tenant", "", "parent_group", "TenantGroup:g-1").Return([]RelationTuple{
|
||||||
|
{Object: "t-inherited-1", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
|
||||||
|
{Object: "t-inherited-2", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// 4. Expect repository to fetch all unique IDs: t-direct-1, t-inherited-1, t-inherited-2
|
||||||
|
expectedIDs := []string{"t-direct-1", "t-inherited-1", "t-inherited-2"}
|
||||||
|
mockRepo.On("FindByIDs", ctx, mock.MatchedBy(func(ids []string) bool {
|
||||||
|
// Check if all expected IDs are present (order doesn't matter since we dedup via map)
|
||||||
|
foundCount := 0
|
||||||
|
for _, eid := range expectedIDs {
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == eid {
|
||||||
|
foundCount++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return foundCount == len(expectedIDs) && len(ids) == len(expectedIDs)
|
||||||
|
})).Return([]domain.Tenant{
|
||||||
|
{ID: "t-direct-1", Name: "Direct Tenant"},
|
||||||
|
{ID: "t-inherited-1", Name: "Inherited Tenant 1"},
|
||||||
|
{ID: "t-inherited-2", Name: "Inherited Tenant 2"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
tenants, err := svc.ListManageableTenants(ctx, userID)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, tenants, 3)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
|
}
|
||||||
@@ -18,8 +18,12 @@ type TenantService interface {
|
|||||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
||||||
|
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||||
ApproveTenant(ctx context.Context, id string) error
|
ApproveTenant(ctx context.Context, id string) error
|
||||||
SetKetoService(keto KetoService) // 추가
|
SetKetoService(keto KetoService) // 추가
|
||||||
|
AddTenantAdmin(ctx context.Context, tenantID, userID string) error
|
||||||
|
RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error
|
||||||
|
ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantService struct {
|
type tenantService struct {
|
||||||
@@ -39,6 +43,60 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
|
|||||||
return s.repo.FindByID(ctx, id)
|
return s.repo.FindByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||||
|
if s.keto == nil {
|
||||||
|
return nil, errors.New("keto service not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get directly managed tenants
|
||||||
|
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get managed tenant groups
|
||||||
|
groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get tenants belonging to those groups
|
||||||
|
var groupInheritedTenantIDs []string
|
||||||
|
for _, groupID := range groupIDs {
|
||||||
|
// In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_
|
||||||
|
// To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_
|
||||||
|
// Wait, my ListObjects lists objects given a subject.
|
||||||
|
// So subject="TenantGroup:"+groupID+"#_"
|
||||||
|
// Object is Tenant ID.
|
||||||
|
ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID)
|
||||||
|
if err == nil {
|
||||||
|
for _, t := range ts {
|
||||||
|
groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine and deduplicate IDs
|
||||||
|
allIDsMap := make(map[string]bool)
|
||||||
|
for _, id := range directTenantIDs {
|
||||||
|
allIDsMap[id] = true
|
||||||
|
}
|
||||||
|
for _, id := range groupInheritedTenantIDs {
|
||||||
|
allIDsMap[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
allIDs := make([]string, 0, len(allIDsMap))
|
||||||
|
for id := range allIDsMap {
|
||||||
|
allIDs = append(allIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allIDs) == 0 {
|
||||||
|
return []domain.Tenant{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.FindByIDs(ctx, allIDs)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
|
||||||
// Validate Slug
|
// Validate Slug
|
||||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||||
@@ -153,3 +211,35 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
|
|||||||
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||||
return s.repo.FindBySlug(ctx, slug)
|
return s.repo.FindBySlug(ctx, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
if s.keto == nil {
|
||||||
|
return errors.New("keto service not initialized")
|
||||||
|
}
|
||||||
|
return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||||
|
if s.keto == nil {
|
||||||
|
return errors.New("keto service not initialized")
|
||||||
|
}
|
||||||
|
return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
|
||||||
|
if s.keto == nil {
|
||||||
|
return nil, errors.New("keto service not initialized")
|
||||||
|
}
|
||||||
|
tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := make([]string, 0, len(tuples))
|
||||||
|
for _, t := range tuples {
|
||||||
|
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
|
||||||
|
userIDs = append(userIDs, t.SubjectID[5:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userIDs, nil
|
||||||
|
}
|
||||||
|
|||||||
22
backend/internal/utils/env.go
Normal file
22
backend/internal/utils/env.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetEnv retrieves the value of the environment variable named by the key.
|
||||||
|
// It returns the value if it exists, otherwise it returns the fallback value.
|
||||||
|
// It automatically strips surrounding double quotes from the value.
|
||||||
|
func GetEnv(key, fallback string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
// Strip surrounding double quotes if present
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
||||||
|
return v[1 : len(v)-1]
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -26,7 +26,8 @@ const badgeVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const buttonVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface TextareaProps
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||||
background-image: radial-gradient(
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
circle at 10% 18%,
|
circle at 10% 18%,
|
||||||
rgba(54, 211, 153, 0.16),
|
rgba(54, 211, 153, 0.16),
|
||||||
transparent 28%
|
transparent 28%
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ function detectLocale(): Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import enRaw from "../../../locales/en.toml?raw";
|
import enRaw from "../locales/en.toml?raw";
|
||||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import koRaw from "../../../locales/ko.toml?raw";
|
import koRaw from "../locales/ko.toml?raw";
|
||||||
|
|
||||||
const translations: Record<Locale, TomlObject> = {
|
const translations: Record<Locale, TomlObject> = {
|
||||||
ko: parseToml(koRaw),
|
ko: parseToml(koRaw),
|
||||||
|
|||||||
1316
devfront/src/locales/en.toml
Normal file
1316
devfront/src/locales/en.toml
Normal file
File diff suppressed because one or more lines are too long
1316
devfront/src/locales/ko.toml
Normal file
1316
devfront/src/locales/ko.toml
Normal file
File diff suppressed because one or more lines are too long
1316
devfront/src/locales/template.toml
Normal file
1316
devfront/src/locales/template.toml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ services:
|
|||||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||||
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||||
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
||||||
|
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
|
||||||
|
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
|
||||||
- DB_HOST=postgres
|
- DB_HOST=postgres
|
||||||
- CLICKHOUSE_HOST=clickhouse
|
- CLICKHOUSE_HOST=clickhouse
|
||||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
@@ -54,6 +56,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- APP_ENV=${APP_ENV:-development}
|
- APP_ENV=${APP_ENV:-development}
|
||||||
- API_PROXY_TARGET=http://baron_backend:3000
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
ports:
|
ports:
|
||||||
- "${ADMIN_PORT:-5173}:5173"
|
- "${ADMIN_PORT:-5173}:5173"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -72,6 +75,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- APP_ENV=${APP_ENV:-development}
|
- APP_ENV=${APP_ENV:-development}
|
||||||
- API_PROXY_TARGET=http://baron_backend:3000
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
ports:
|
ports:
|
||||||
- "${DEVFRONT_PORT:-5174}:5173"
|
- "${DEVFRONT_PORT:-5174}:5173"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
|
|||||||
|
|
||||||
class User implements Namespace {}
|
class User implements Namespace {}
|
||||||
|
|
||||||
|
class TenantGroup implements Namespace {
|
||||||
|
related: {
|
||||||
|
admins: User[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UserGroup implements Namespace {
|
class UserGroup implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: User[]
|
members: User[]
|
||||||
@@ -19,17 +25,20 @@ class Tenant implements Namespace {
|
|||||||
admins: User[]
|
admins: User[]
|
||||||
members: User[]
|
members: User[]
|
||||||
parent: Tenant[]
|
parent: Tenant[]
|
||||||
|
parent_group: TenantGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
view: (ctx: Context): boolean =>
|
view: (ctx: Context): boolean =>
|
||||||
this.related.members.includes(ctx.subject) ||
|
this.related.members.includes(ctx.subject) ||
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
this.related.parent.traverse((p) => p.permits.view(ctx)),
|
this.related.parent.traverse((p) => p.permits.view(ctx)) ||
|
||||||
|
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
||||||
|
|
||||||
manage: (ctx: Context): boolean =>
|
manage: (ctx: Context): boolean =>
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
this.related.parent.traverse((p) => p.permits.manage(ctx)),
|
this.related.parent.traverse((p) => p.permits.manage(ctx)) ||
|
||||||
|
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
|
||||||
|
|
||||||
create_subtenant: (ctx: Context): boolean =>
|
create_subtenant: (ctx: Context): boolean =>
|
||||||
this.permits.manage(ctx)
|
this.permits.manage(ctx)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ selfservice:
|
|||||||
- https://sso.hmac.kr/
|
- https://sso.hmac.kr/
|
||||||
- https://app.hmac.kr
|
- https://app.hmac.kr
|
||||||
- https://app.hmac.kr/
|
- https://app.hmac.kr/
|
||||||
|
- https://ssologin.hmac.kr
|
||||||
|
- https://ssologin.hmac.kr/
|
||||||
|
|
||||||
methods:
|
methods:
|
||||||
password:
|
password:
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ server {
|
|||||||
client_header_buffer_size 16k;
|
client_header_buffer_size 16k;
|
||||||
large_client_header_buffers 4 64k;
|
large_client_header_buffers 4 64k;
|
||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
|
types {
|
||||||
|
application/javascript mjs;
|
||||||
|
}
|
||||||
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $backend_upstream http://baron_backend:3000;
|
set $backend_upstream http://baron_backend:3000;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ missing = "Missing"
|
|||||||
[msg]
|
[msg]
|
||||||
|
|
||||||
[msg.admin]
|
[msg.admin]
|
||||||
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
idp_env_prod = "IDP env: prod"
|
idp_env_prod = "IDP env: prod"
|
||||||
scope_admin = "Scoped to /admin"
|
scope_admin = "Scoped to /admin"
|
||||||
session_ttl = "Session TTL: 15m admin"
|
session_ttl = "Session TTL: 15m admin"
|
||||||
@@ -841,9 +842,6 @@ role = "ROLE"
|
|||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
tenant_dept = "TENANT / DEPT"
|
tenant_dept = "TENANT / DEPT"
|
||||||
|
|
||||||
[ui.btn]
|
|
||||||
cancel = "Cancel"
|
|
||||||
save = "Save"
|
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = "Add"
|
add = "Add"
|
||||||
@@ -1091,8 +1089,6 @@ title = "Stack readiness"
|
|||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|
||||||
[ui.nav]
|
|
||||||
dashboard = "Dashboard"
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = "App Title"
|
app_title = "App Title"
|
||||||
@@ -1306,3 +1302,15 @@ verify = "Verify"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Action"
|
action = "Action"
|
||||||
|
|
||||||
|
[ui.admin.nav]
|
||||||
|
api_keys = "API Keys"
|
||||||
|
audit_logs = "Audit Logs"
|
||||||
|
auth_guard = "Auth Guard"
|
||||||
|
logout = "Logout"
|
||||||
|
overview = "Overview"
|
||||||
|
relying_parties = "Apps (RP)"
|
||||||
|
tenant_dashboard = "Tenant Dashboard"
|
||||||
|
tenant_groups = "Tenant Groups"
|
||||||
|
tenants = "Tenants"
|
||||||
|
users = "Users"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ missing = "활성 세션이 없습니다."
|
|||||||
[msg]
|
[msg]
|
||||||
|
|
||||||
[msg.admin]
|
[msg.admin]
|
||||||
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
idp_env_prod = "IDP env: prod"
|
idp_env_prod = "IDP env: prod"
|
||||||
scope_admin = "Scoped to /admin"
|
scope_admin = "Scoped to /admin"
|
||||||
session_ttl = "Session TTL: 15m admin"
|
session_ttl = "Session TTL: 15m admin"
|
||||||
@@ -841,9 +842,6 @@ role = "ROLE"
|
|||||||
status = "STATUS"
|
status = "STATUS"
|
||||||
tenant_dept = "TENANT / DEPT"
|
tenant_dept = "TENANT / DEPT"
|
||||||
|
|
||||||
[ui.btn]
|
|
||||||
cancel = "취소"
|
|
||||||
save = "저장"
|
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = "추가"
|
add = "추가"
|
||||||
@@ -1091,8 +1089,6 @@ title = "Stack readiness"
|
|||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|
||||||
[ui.nav]
|
|
||||||
dashboard = "대시보드"
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = "Baron 로그인"
|
app_title = "Baron 로그인"
|
||||||
@@ -1306,3 +1302,15 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
[ui.admin.nav]
|
||||||
|
api_keys = "API 키"
|
||||||
|
audit_logs = "감사 로그"
|
||||||
|
auth_guard = "인증 가드"
|
||||||
|
logout = "로그아웃"
|
||||||
|
overview = "개요"
|
||||||
|
relying_parties = "애플리케이션(RP)"
|
||||||
|
tenant_dashboard = "테넌트 대시보드"
|
||||||
|
tenant_groups = "테넌트 그룹"
|
||||||
|
tenants = "테넌트"
|
||||||
|
users = "사용자"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ missing = ""
|
|||||||
|
|
||||||
[msg.admin]
|
[msg.admin]
|
||||||
idp_env_prod = ""
|
idp_env_prod = ""
|
||||||
|
logout_confirm = ""
|
||||||
scope_admin = ""
|
scope_admin = ""
|
||||||
session_ttl = ""
|
session_ttl = ""
|
||||||
tenant_headers = ""
|
tenant_headers = ""
|
||||||
@@ -659,6 +660,18 @@ name = ""
|
|||||||
[ui.admin.header]
|
[ui.admin.header]
|
||||||
plane = ""
|
plane = ""
|
||||||
|
|
||||||
|
[ui.admin.nav]
|
||||||
|
api_keys = ""
|
||||||
|
audit_logs = ""
|
||||||
|
auth_guard = ""
|
||||||
|
logout = ""
|
||||||
|
overview = ""
|
||||||
|
relying_parties = ""
|
||||||
|
tenant_dashboard = ""
|
||||||
|
tenant_groups = ""
|
||||||
|
tenants = ""
|
||||||
|
users = ""
|
||||||
|
|
||||||
[ui.admin.overview]
|
[ui.admin.overview]
|
||||||
kicker = ""
|
kicker = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -841,9 +854,6 @@ role = ""
|
|||||||
status = ""
|
status = ""
|
||||||
tenant_dept = ""
|
tenant_dept = ""
|
||||||
|
|
||||||
[ui.btn]
|
|
||||||
cancel = ""
|
|
||||||
save = ""
|
|
||||||
|
|
||||||
[ui.common]
|
[ui.common]
|
||||||
add = ""
|
add = ""
|
||||||
@@ -1091,8 +1101,6 @@ title = ""
|
|||||||
plane = ""
|
plane = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[ui.nav]
|
|
||||||
dashboard = ""
|
|
||||||
|
|
||||||
[ui.userfront]
|
[ui.userfront]
|
||||||
app_title = ""
|
app_title = ""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ COPY . .
|
|||||||
# Get dependencies and build for web
|
# Get dependencies and build for web
|
||||||
RUN flutter pub get
|
RUN flutter pub get
|
||||||
RUN touch .env
|
RUN touch .env
|
||||||
RUN flutter build web --release --no-tree-shake-icons --wasm
|
RUN flutter build web --release --no-tree-shake-icons
|
||||||
|
|
||||||
# Stage 2: Serve with Nginx
|
# Stage 2: Serve with Nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ class AuditService {
|
|||||||
return dotenv.env[key] ?? fallback;
|
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({
|
static Future<void> logEvent({
|
||||||
required String userId,
|
required String userId,
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ class AuthProxyService {
|
|||||||
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
|
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
|
||||||
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
|
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get _isProd {
|
static bool get _isProd {
|
||||||
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
|
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
|
||||||
return env == 'prod' || env == 'production';
|
return env == 'prod' || env == 'production';
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get isProdEnv => _isProd;
|
static bool get isProdEnv => _isProd;
|
||||||
static bool _shouldSendDrySend(bool? drySend) {
|
static bool _shouldSendDrySend(bool? drySend) {
|
||||||
if (_isProd) {
|
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 url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
try {
|
try {
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null && token.isNotEmpty) {
|
if (!useCookie && token != null && token.isNotEmpty) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -102,10 +105,7 @@ class AuthProxyService {
|
|||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||||
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
||||||
|
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
|
||||||
'loginId': loginId,
|
|
||||||
'uri': userfrontUrl,
|
|
||||||
};
|
|
||||||
if (_shouldSendDrySend(drySend)) {
|
if (_shouldSendDrySend(drySend)) {
|
||||||
body['drySend'] = true;
|
body['drySend'] = true;
|
||||||
}
|
}
|
||||||
@@ -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 url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'pendingRef': pendingRef}),
|
||||||
'pendingRef': pendingRef,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
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 url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}),
|
||||||
'token': token,
|
|
||||||
'verifyOnly': verifyOnly,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -223,10 +223,7 @@ class AuthProxyService {
|
|||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}),
|
||||||
'shortCode': shortCode,
|
|
||||||
'verifyOnly': verifyOnly,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
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 url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
|
||||||
|
|
||||||
final payload = {
|
final payload = {
|
||||||
'loginId': loginId,
|
'loginId': loginId,
|
||||||
'password': password,
|
'password': password,
|
||||||
if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge,
|
if (loginChallenge != null && loginChallenge.isNotEmpty)
|
||||||
|
'login_challenge': loginChallenge,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await http.post(
|
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(
|
final response = await http.get(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
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 url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{'consent_challenge': consentChallenge};
|
||||||
'consent_challenge': consentChallenge,
|
|
||||||
};
|
|
||||||
if (grantScope != null) {
|
if (grantScope != null) {
|
||||||
body['grant_scope'] = grantScope;
|
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 url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{'consent_challenge': consentChallenge};
|
||||||
'consent_challenge': consentChallenge,
|
|
||||||
};
|
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
@@ -353,9 +361,7 @@ class AuthProxyService {
|
|||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -364,9 +370,7 @@ class AuthProxyService {
|
|||||||
final response = await client.post(
|
final response = await client.post(
|
||||||
url,
|
url,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: jsonEncode({
|
body: jsonEncode({'login_challenge': loginChallenge}),
|
||||||
'login_challenge': loginChallenge,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -386,8 +390,10 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> initiatePasswordReset(
|
||||||
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async {
|
String loginId, {
|
||||||
|
bool? drySend,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
@@ -424,7 +430,9 @@ class AuthProxyService {
|
|||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
query['token'] = token;
|
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(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@@ -451,9 +459,7 @@ class AuthProxyService {
|
|||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'phoneNumber': phoneNumber}),
|
||||||
'phoneNumber': phoneNumber,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
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 url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}),
|
||||||
'phoneNumber': phoneNumber,
|
|
||||||
'code': code,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
@@ -532,10 +538,10 @@ class AuthProxyService {
|
|||||||
String? token,
|
String? token,
|
||||||
bool withCredentials = false,
|
bool withCredentials = false,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
|
final url = Uri.parse(
|
||||||
final payload = <String, dynamic>{
|
'$_baseUrl/api/v1/auth/qr/approve',
|
||||||
'pendingRef': pendingRef,
|
); // Mapping to ScanQRLogin on backend
|
||||||
};
|
final payload = <String, dynamic>{'pendingRef': pendingRef};
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
payload['token'] = token;
|
payload['token'] = token;
|
||||||
}
|
}
|
||||||
@@ -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');
|
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
|
||||||
if (query != null && query.isNotEmpty) {
|
if (query != null && query.isNotEmpty) {
|
||||||
uri = uri.replace(queryParameters: {'text': query});
|
uri = uri.replace(queryParameters: {'text': query});
|
||||||
@@ -664,7 +673,11 @@ 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 encodedId = Uri.encodeComponent(loginId);
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
|
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
|
||||||
|
|
||||||
@@ -725,18 +738,13 @@ class AuthProxyService {
|
|||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await client.get(
|
final response = await client.get(url, headers: headers);
|
||||||
url,
|
|
||||||
headers: headers,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
@@ -758,18 +766,13 @@ class AuthProxyService {
|
|||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await client.delete(
|
final response = await client.delete(url, headers: headers);
|
||||||
url,
|
|
||||||
headers: headers,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
final errorBody = jsonDecode(response.body);
|
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()) {
|
if (!_canSendClientLog()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -808,7 +815,11 @@ 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>{};
|
final data = <String, dynamic>{};
|
||||||
if (error != null) data['error'] = error.toString();
|
if (error != null) data['error'] = error.toString();
|
||||||
if (stackTrace != null) data['stack'] = stackTrace.toString();
|
if (stackTrace != null) data['stack'] = stackTrace.toString();
|
||||||
@@ -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 url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({'target': target, 'type': type, 'code': code}),
|
||||||
'target': target,
|
|
||||||
'type': type,
|
|
||||||
'code': code,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'http_client_stub.dart'
|
import 'http_client_stub.dart' if (dart.library.html) 'http_client_web.dart';
|
||||||
if (dart.library.html) 'http_client_web.dart';
|
|
||||||
|
|
||||||
http.Client createHttpClient({bool withCredentials = false}) {
|
http.Client createHttpClient({bool withCredentials = false}) {
|
||||||
return httpClientFactory.create(withCredentials: withCredentials);
|
return httpClientFactory.create(withCredentials: withCredentials);
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ class LoggerService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 2. Configure Standard Logger (logging package)
|
// 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) {
|
std_log.Logger.root.onRecord.listen((record) {
|
||||||
if (kReleaseMode) {
|
if (kReleaseMode) {
|
||||||
@@ -47,7 +49,11 @@ class LoggerService {
|
|||||||
|
|
||||||
void _logPretty(std_log.LogRecord record) {
|
void _logPretty(std_log.LogRecord record) {
|
||||||
if (record.level >= std_log.Level.SEVERE) {
|
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) {
|
} else if (record.level >= std_log.Level.WARNING) {
|
||||||
_prettyLogger.w(record.message);
|
_prettyLogger.w(record.message);
|
||||||
} else if (record.level >= std_log.Level.INFO) {
|
} else if (record.level >= std_log.Level.INFO) {
|
||||||
|
|||||||
@@ -3,9 +3,64 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:html' as html;
|
import 'dart:html' as html;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'auth_token_store.dart';
|
||||||
|
|
||||||
void implSendLoginSuccess(String token) {
|
void implSendLoginSuccess(String token) {
|
||||||
final message = {'type': 'LOGIN_SUCCESS', 'token': token};
|
var effectiveToken = token;
|
||||||
|
if (effectiveToken.isEmpty) {
|
||||||
|
effectiveToken = AuthTokenStore.getToken() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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'];
|
||||||
|
|
||||||
|
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 fallback: regex or manual search in fullUrl
|
||||||
|
if (redirectUri == null) {
|
||||||
|
for (final key in ['redirect_uri=', 'redirect_url=']) {
|
||||||
|
if (fullUrl.contains(key)) {
|
||||||
|
final start = fullUrl.indexOf(key) + key.length;
|
||||||
|
var end = fullUrl.indexOf('&', start);
|
||||||
|
if (end == -1) end = fullUrl.length;
|
||||||
|
final raw = fullUrl.substring(start, end);
|
||||||
|
try {
|
||||||
|
redirectUri = Uri.decodeComponent(raw);
|
||||||
|
break;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUri != null && redirectUri.isNotEmpty) {
|
||||||
|
// Redirection flow
|
||||||
|
final target = Uri.parse(redirectUri);
|
||||||
|
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) {
|
if (html.window.opener != null) {
|
||||||
try {
|
try {
|
||||||
@@ -20,11 +75,19 @@ void implSendLoginSuccess(String token) {
|
|||||||
html.window.close();
|
html.window.close();
|
||||||
});
|
});
|
||||||
} else {
|
} 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.');
|
debugPrint('No opener found during popup flow.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool implIsPopup() {
|
bool implIsPopup() {
|
||||||
return html.window.opener != null;
|
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;
|
||||||
|
|
||||||
|
// Manual parse fallback for cases where Uri.base might miss params due to hash routing
|
||||||
|
final search = html.window.location.search;
|
||||||
|
return search != null && search.contains('source=adminfront');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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
|
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('@')) {
|
if (phone != null && !phone.contains('@')) {
|
||||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||||
if (phone.startsWith('010')) {
|
if (phone.startsWith('010')) {
|
||||||
@@ -128,14 +133,21 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
await AuthProxyService.createUser(
|
await AuthProxyService.createUser(
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
adminPassword: _verifiedAdminPassword!,
|
adminPassword: _verifiedAdminPassword!,
|
||||||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
email: _emailController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _emailController.text.trim(),
|
||||||
phone: phone,
|
phone: phone,
|
||||||
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
|
displayName: _nameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _nameController.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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();
|
_formKey.currentState!.reset();
|
||||||
_loginIdController.clear();
|
_loginIdController.clear();
|
||||||
@@ -158,9 +170,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Hide content until authorized
|
// Hide content until authorized
|
||||||
if (!_isAuthorized) {
|
if (!_isAuthorized) {
|
||||||
return const Scaffold(
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
body: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -194,7 +204,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
helperText: "Unique identifier (Email or Phone)",
|
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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ class UserManagementScreen extends StatefulWidget {
|
|||||||
State<UserManagementScreen> createState() => _UserManagementScreenState();
|
State<UserManagementScreen> createState() => _UserManagementScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserManagementScreenState extends State<UserManagementScreen> with SingleTickerProviderStateMixin {
|
class _UserManagementScreenState extends State<UserManagementScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
bool _isAuthorized = false;
|
bool _isAuthorized = false;
|
||||||
String? _verifiedAdminPassword;
|
String? _verifiedAdminPassword;
|
||||||
@@ -23,7 +24,8 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
|
|
||||||
// --- Create Tab Controllers ---
|
// --- Create Tab Controllers ---
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final TextEditingController _createLoginIdController = TextEditingController();
|
final TextEditingController _createLoginIdController =
|
||||||
|
TextEditingController();
|
||||||
final TextEditingController _createEmailController = TextEditingController();
|
final TextEditingController _createEmailController = TextEditingController();
|
||||||
final TextEditingController _createPhoneController = TextEditingController();
|
final TextEditingController _createPhoneController = TextEditingController();
|
||||||
final TextEditingController _createNameController = TextEditingController();
|
final TextEditingController _createNameController = TextEditingController();
|
||||||
@@ -64,15 +66,24 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
TextField(
|
TextField(
|
||||||
controller: passwordController,
|
controller: passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: const InputDecoration(labelText: "Password", border: OutlineInputBorder()),
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Password",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onSubmitted: (value) => Navigator.pop(context, value),
|
onSubmitted: (value) => Navigator.pop(context, value),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("Cancel")),
|
TextButton(
|
||||||
FilledButton(onPressed: () => Navigator.pop(context, passwordController.text), child: const Text("Enter")),
|
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 {
|
} else {
|
||||||
if (mounted) {
|
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('/');
|
context.go('/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +123,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
if (_verifiedAdminPassword == null) return;
|
if (_verifiedAdminPassword == null) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final users = await AuthProxyService.listUsers(_verifiedAdminPassword!, query: query);
|
final users = await AuthProxyService.listUsers(
|
||||||
|
_verifiedAdminPassword!,
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
setState(() => _users = users);
|
setState(() => _users = users);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showError("Failed to load users: $e");
|
_showError("Failed to load users: $e");
|
||||||
@@ -130,13 +149,18 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text("Delete User"),
|
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: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
style: FilledButton.styleFrom(backgroundColor: Colors.red),
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text("Delete")
|
child: const Text("Delete"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -158,11 +182,17 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
|
|
||||||
Future<void> _toggleStatus(String loginId, String currentStatus) async {
|
Future<void> _toggleStatus(String loginId, String currentStatus) async {
|
||||||
if (_verifiedAdminPassword == null) return;
|
if (_verifiedAdminPassword == null) return;
|
||||||
final newStatus = (currentStatus == "enabled" || currentStatus == "active") ? "disabled" : "enabled";
|
final newStatus = (currentStatus == "enabled" || currentStatus == "active")
|
||||||
|
? "disabled"
|
||||||
|
: "enabled";
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.updateUserStatus(_verifiedAdminPassword!, loginId, newStatus);
|
await AuthProxyService.updateUserStatus(
|
||||||
|
_verifiedAdminPassword!,
|
||||||
|
loginId,
|
||||||
|
newStatus,
|
||||||
|
);
|
||||||
_showSuccess("User status updated to $newStatus");
|
_showSuccess("User status updated to $newStatus");
|
||||||
_loadUsers(query: _searchController.text);
|
_loadUsers(query: _searchController.text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -179,9 +209,15 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
|
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
|
||||||
if (loginId.isEmpty) return;
|
if (loginId.isEmpty) return;
|
||||||
|
|
||||||
final nameController = TextEditingController(text: user['name'] ?? user['user']?['name'] ?? "");
|
final nameController = TextEditingController(
|
||||||
final emailController = TextEditingController(text: user['user']?['email'] ?? "");
|
text: user['name'] ?? user['user']?['name'] ?? "",
|
||||||
final phoneController = TextEditingController(text: user['user']?['phone'] ?? "");
|
);
|
||||||
|
final emailController = TextEditingController(
|
||||||
|
text: user['user']?['email'] ?? "",
|
||||||
|
);
|
||||||
|
final phoneController = TextEditingController(
|
||||||
|
text: user['user']?['phone'] ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -190,14 +226,29 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TextField(controller: nameController, decoration: const InputDecoration(labelText: "Name")),
|
TextField(
|
||||||
TextField(controller: emailController, decoration: const InputDecoration(labelText: "Email")),
|
controller: nameController,
|
||||||
TextField(controller: phoneController, decoration: const InputDecoration(labelText: "Phone")),
|
decoration: const InputDecoration(labelText: "Name"),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: emailController,
|
||||||
|
decoration: const InputDecoration(labelText: "Email"),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: phoneController,
|
||||||
|
decoration: const InputDecoration(labelText: "Phone"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
|
TextButton(
|
||||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text("Save")),
|
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);
|
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('@')) {
|
if (phone != null && !phone.contains('@')) {
|
||||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||||
if (phone.startsWith('010')) {
|
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('@')) {
|
if (phone != null && !phone.contains('@')) {
|
||||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||||
if (phone.startsWith('010')) {
|
if (phone.startsWith('010')) {
|
||||||
@@ -258,9 +313,13 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
await AuthProxyService.createUser(
|
await AuthProxyService.createUser(
|
||||||
loginId: loginId,
|
loginId: loginId,
|
||||||
adminPassword: _verifiedAdminPassword!,
|
adminPassword: _verifiedAdminPassword!,
|
||||||
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
|
email: _createEmailController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _createEmailController.text.trim(),
|
||||||
phone: phone,
|
phone: phone,
|
||||||
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(),
|
displayName: _createNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _createNameController.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
_showSuccess("User created successfully");
|
_showSuccess("User created successfully");
|
||||||
@@ -273,7 +332,6 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
// Switch to list tab and reload
|
// Switch to list tab and reload
|
||||||
_tabController.animateTo(0);
|
_tabController.animateTo(0);
|
||||||
_loadUsers();
|
_loadUsers();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showError("Error: $e");
|
_showError("Error: $e");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -284,12 +342,16 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
// --- UI Helpers ---
|
// --- UI Helpers ---
|
||||||
void _showError(String msg) {
|
void _showError(String msg) {
|
||||||
if (!mounted) return;
|
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) {
|
void _showSuccess(String msg) {
|
||||||
if (!mounted) return;
|
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
|
@override
|
||||||
@@ -315,10 +377,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
),
|
),
|
||||||
body: TabBarView(
|
body: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [_buildUserListTab(), _buildCreateUserTab()],
|
||||||
_buildUserListTab(),
|
|
||||||
_buildCreateUserTab(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -351,22 +410,39 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
|
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
|
||||||
|
|
||||||
final loginIDs = (user['loginIds'] as List?) ?? [];
|
final loginIDs = (user['loginIds'] as List?) ?? [];
|
||||||
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID";
|
final loginId = loginIDs.isNotEmpty
|
||||||
final name = user['name'] ?? user['user']?['name'] ?? "No Name";
|
? loginIDs.first.toString()
|
||||||
|
: "Unknown ID";
|
||||||
|
final name =
|
||||||
|
user['name'] ?? user['user']?['name'] ?? "No Name";
|
||||||
final status = user['status'] ?? "unknown";
|
final status = user['status'] ?? "unknown";
|
||||||
final isEnabled = status == "enabled" || status == "active";
|
final isEnabled = status == "enabled" || status == "active";
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: isEnabled ? Colors.green.shade100 : Colors.grey.shade300,
|
backgroundColor: isEnabled
|
||||||
child: Icon(Icons.person, color: isEnabled ? Colors.green : Colors.grey),
|
? Colors.green.shade100
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: isEnabled ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(name),
|
title: Text(name),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(loginId, style: const TextStyle(fontWeight: FontWeight.bold)),
|
Text(
|
||||||
Text("Status: $status", style: TextStyle(color: isEnabled ? Colors.green : Colors.red, fontSize: 12)),
|
loginId,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Status: $status",
|
||||||
|
style: TextStyle(
|
||||||
|
color: isEnabled ? Colors.green : Colors.red,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
@@ -378,7 +454,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
onPressed: () => _editUser(user),
|
onPressed: () => _editUser(user),
|
||||||
),
|
),
|
||||||
IconButton(
|
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",
|
tooltip: isEnabled ? "Disable User" : "Enable User",
|
||||||
onPressed: () => _toggleStatus(loginId, status),
|
onPressed: () => _toggleStatus(loginId, status),
|
||||||
),
|
),
|
||||||
@@ -417,27 +496,44 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
helperText: "Unique identifier (Email or Phone)",
|
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),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _createNameController,
|
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),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _createEmailController,
|
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),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _createPhoneController,
|
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),
|
const SizedBox(height: 32),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _isLoading ? null : _createUserSubmit,
|
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"),
|
child: const Text("Create User"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
padding: const EdgeInsets.only(bottom: 20),
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
child: Text(
|
child: Text(
|
||||||
_message!,
|
_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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
|
|
||||||
Future<void> _fetchConsentInfo() async {
|
Future<void> _fetchConsentInfo() async {
|
||||||
try {
|
try {
|
||||||
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
|
final info = await AuthProxyService.getConsentInfo(
|
||||||
|
widget.consentChallenge,
|
||||||
|
);
|
||||||
|
|
||||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||||
if (info['redirectTo'] != null) {
|
if (info['redirectTo'] != null) {
|
||||||
@@ -56,7 +58,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
details.forEach((scope, detail) {
|
details.forEach((scope, detail) {
|
||||||
if (detail is Map<String, dynamic>) {
|
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();
|
_scopeDescriptions[scope] = detail['description'].toString();
|
||||||
}
|
}
|
||||||
// 필수 여부 업데이트
|
// 필수 여부 업데이트
|
||||||
@@ -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);
|
_selectedScopes.addAll(requestedScopes);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
try {
|
try {
|
||||||
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
|
final resp = await AuthProxyService.rejectConsent(
|
||||||
|
widget.consentChallenge,
|
||||||
|
);
|
||||||
final redirectTo = resp['redirectTo'];
|
final redirectTo = resp['redirectTo'];
|
||||||
if (redirectTo != null) {
|
if (redirectTo != null) {
|
||||||
webWindow.redirectTo(redirectTo);
|
webWindow.redirectTo(redirectTo);
|
||||||
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,8 +175,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: _error != null
|
: _error != null
|
||||||
? _buildErrorCard()
|
? _buildErrorCard()
|
||||||
: _buildConsentCard(context),
|
: _buildConsentCard(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
|
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
|
||||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
||||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
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(
|
return SingleChildScrollView(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (clientLogo != null && clientLogo.toString().isNotEmpty)
|
if (clientLogo != null &&
|
||||||
|
clientLogo.toString().isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 16),
|
padding: const EdgeInsets.only(right: 16),
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'요청된 권한',
|
'요청된 권한',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'총 ${requestedScopes.length}개',
|
'총 ${requestedScopes.length}개',
|
||||||
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'동의하고 계속하기',
|
'동의하고 계속하기',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -32,30 +32,36 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
final title = isProd
|
final title = isProd
|
||||||
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
|
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
|
||||||
: (hasCode
|
: (hasCode
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.error.title_with_code',
|
'msg.userfront.error.title_with_code',
|
||||||
fallback: '오류: {{code}}',
|
fallback: '오류: {{code}}',
|
||||||
params: {'code': normalizedCode},
|
params: {'code': normalizedCode},
|
||||||
)
|
)
|
||||||
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
|
: tr(
|
||||||
|
'msg.userfront.error.title_generic',
|
||||||
|
fallback: '오류가 발생했습니다',
|
||||||
|
));
|
||||||
final detail = isProd
|
final detail = isProd
|
||||||
? (isWhitelisted
|
? (isWhitelisted
|
||||||
? tr(
|
? tr(
|
||||||
'msg.userfront.error.whitelist.$normalizedCode',
|
'msg.userfront.error.whitelist.$normalizedCode',
|
||||||
fallback: whitelistFallback,
|
fallback: whitelistFallback,
|
||||||
)
|
)
|
||||||
: tr(
|
: tr(
|
||||||
'msg.userfront.error.detail_contact',
|
'msg.userfront.error.detail_contact',
|
||||||
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
||||||
))
|
))
|
||||||
: ((description?.isNotEmpty == true)
|
: ((description?.isNotEmpty == true)
|
||||||
? description!
|
? description!
|
||||||
: (hasCode
|
: (hasCode
|
||||||
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
|
? tr(
|
||||||
: tr(
|
'msg.userfront.error.detail_generic',
|
||||||
'msg.userfront.error.detail_request',
|
fallback: '오류가 발생했습니다.',
|
||||||
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
|
)
|
||||||
)));
|
: tr(
|
||||||
|
'msg.userfront.error.detail_request',
|
||||||
|
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
|
||||||
|
)));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F8FA),
|
backgroundColor: const Color(0xFFF7F8FA),
|
||||||
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF111827),
|
backgroundColor: const Color(0xFF111827),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
|
tr(
|
||||||
|
'ui.userfront.error.go_login',
|
||||||
|
fallback: '로그인으로 이동',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => context.go('/'),
|
onPressed: () => context.go('/'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF111827),
|
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)),
|
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
_drySendEnabled =
|
||||||
|
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||||
|
!AuthProxyService.isProdEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePasswordReset() async {
|
Future<void> _handlePasswordReset() async {
|
||||||
@@ -44,7 +46,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
|
await AuthProxyService.initiatePasswordReset(
|
||||||
|
loginId,
|
||||||
|
drySend: _drySendEnabled,
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -107,16 +112,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
|
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (_drySendEnabled) ...[
|
if (_drySendEnabled) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFF3CD),
|
color: const Color(0xFFFFF3CD),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -124,7 +129,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Color(0xFF8A6D3B),
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -132,7 +140,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
'msg.userfront.forgot.dry_send',
|
'msg.userfront.forgot.dry_send',
|
||||||
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
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(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
tr(
|
tr('ui.userfront.forgot.submit', fallback: '재설정 링크 전송'),
|
||||||
'ui.userfront.forgot.submit',
|
|
||||||
fallback: '재설정 링크 전송',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,8 +16,14 @@ import '../../../core/services/web_window.dart';
|
|||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
final String? verificationToken;
|
final String? verificationToken;
|
||||||
final String? loginChallenge;
|
final String? loginChallenge;
|
||||||
|
final String? redirectUrl;
|
||||||
|
|
||||||
const LoginScreen({super.key, this.verificationToken, this.loginChallenge});
|
const LoginScreen({
|
||||||
|
super.key,
|
||||||
|
this.verificationToken,
|
||||||
|
this.loginChallenge,
|
||||||
|
this.redirectUrl,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
@@ -27,7 +33,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
final TextEditingController _linkIdController = TextEditingController();
|
final TextEditingController _linkIdController = TextEditingController();
|
||||||
final TextEditingController _passwordLoginIdController = TextEditingController();
|
final TextEditingController _passwordLoginIdController =
|
||||||
|
TextEditingController();
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
String? _redirectUrl;
|
String? _redirectUrl;
|
||||||
String? _loginChallenge;
|
String? _loginChallenge;
|
||||||
@@ -40,8 +47,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
int _qrRemainingSeconds = 0;
|
int _qrRemainingSeconds = 0;
|
||||||
Timer? _qrCountdownTimer;
|
Timer? _qrCountdownTimer;
|
||||||
int _qrPollIntervalMs = 2000;
|
int _qrPollIntervalMs = 2000;
|
||||||
final TextEditingController _shortCodePrefixController = TextEditingController();
|
final TextEditingController _shortCodePrefixController =
|
||||||
final TextEditingController _shortCodeDigitsController = TextEditingController();
|
TextEditingController();
|
||||||
|
final TextEditingController _shortCodeDigitsController =
|
||||||
|
TextEditingController();
|
||||||
String? _linkPendingRef;
|
String? _linkPendingRef;
|
||||||
String? _lastLinkLoginId;
|
String? _lastLinkLoginId;
|
||||||
bool _lastLinkIsEmail = true;
|
bool _lastLinkIsEmail = true;
|
||||||
@@ -74,19 +83,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||||
_tabController.addListener(_handleTabSelection);
|
_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 {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
_loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge'];
|
|
||||||
|
if (_redirectUrl == null) {
|
||||||
|
if (uri.queryParameters.containsKey('redirect_url')) {
|
||||||
|
_redirectUrl = uri.queryParameters['redirect_url'];
|
||||||
|
} else if (uri.queryParameters.containsKey('redirect_uri')) {
|
||||||
|
_redirectUrl = uri.queryParameters['redirect_uri'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loginChallenge =
|
||||||
|
widget.loginChallenge ?? uri.queryParameters['login_challenge'];
|
||||||
final loginIdParam = uri.queryParameters['loginId'];
|
final loginIdParam = uri.queryParameters['loginId'];
|
||||||
final codeParam = uri.queryParameters['code'];
|
final codeParam = uri.queryParameters['code'];
|
||||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
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 hasTokenParam = uri.queryParameters.containsKey('t');
|
||||||
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
|
final hasVerificationToken =
|
||||||
|
widget.verificationToken != null || hasTokenParam;
|
||||||
final hasLoginCode = loginIdParam != null && codeParam != null;
|
final hasLoginCode = loginIdParam != null && codeParam != null;
|
||||||
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
|
_verificationOnly =
|
||||||
|
hasVerificationToken || hasLoginCode || hasShortCodePath;
|
||||||
final notice = uri.queryParameters['notice'];
|
final notice = uri.queryParameters['notice'];
|
||||||
|
|
||||||
if (hasShortCodePath) {
|
if (hasShortCodePath) {
|
||||||
@@ -114,10 +139,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _tryCookieSession();
|
await _tryCookieSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uri.queryParameters.containsKey('redirect_url')) {
|
|
||||||
_redirectUrl = uri.queryParameters['redirect_url'];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,9 +164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.cookie_check_failed',
|
'msg.userfront.login.cookie_check_failed',
|
||||||
fallback: '로그인 확인 실패: {{error}}',
|
fallback: '로그인 확인 실패: {{error}}',
|
||||||
params: {
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||||
'error': e.toString().replaceFirst('Exception: ', ''),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,6 +180,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final token = AuthTokenStore.getToken();
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
if (WebAuthIntegration.isPopup() ||
|
||||||
|
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||||
|
debugPrint(
|
||||||
|
"[Auth] Cookie session with external integration. Notifying...",
|
||||||
|
);
|
||||||
|
WebAuthIntegration.sendLoginSuccess(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.go('/');
|
context.go('/');
|
||||||
}
|
}
|
||||||
@@ -183,7 +215,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.checkCookieSession();
|
await AuthProxyService.checkCookieSession();
|
||||||
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory');
|
AuthTokenStore.setCookieMode(
|
||||||
|
provider: AuthTokenStore.getProvider() ?? 'ory',
|
||||||
|
);
|
||||||
await _acceptOidcLoginAndRedirect();
|
await _acceptOidcLoginAndRedirect();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
|
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
|
||||||
@@ -272,7 +306,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final parts = jwt.split('.');
|
final parts = jwt.split('.');
|
||||||
if (parts.length != 3) return 'User';
|
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);
|
final data = json.decode(payload);
|
||||||
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
|
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -343,65 +379,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
void _startQrPolling() {
|
void _startQrPolling() {
|
||||||
_qrPollingTimer?.cancel();
|
_qrPollingTimer?.cancel();
|
||||||
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
|
_qrPollingTimer = Timer.periodic(
|
||||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
Duration(milliseconds: _qrPollIntervalMs),
|
||||||
timer.cancel();
|
(timer) async {
|
||||||
return;
|
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||||
}
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||||
if (res['error'] == 'slow_down') {
|
if (res['error'] == 'slow_down') {
|
||||||
final interval = res['interval'];
|
final interval = res['interval'];
|
||||||
if (interval is int && interval > 0) {
|
if (interval is int && interval > 0) {
|
||||||
final nextIntervalMs = interval * 1000;
|
final nextIntervalMs = interval * 1000;
|
||||||
if (nextIntervalMs != _qrPollIntervalMs) {
|
if (nextIntervalMs != _qrPollIntervalMs) {
|
||||||
_qrPollIntervalMs = nextIntervalMs;
|
_qrPollIntervalMs = nextIntervalMs;
|
||||||
|
timer.cancel();
|
||||||
|
_startQrPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_qrPollIntervalMs += 500;
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
_startQrPolling();
|
_startQrPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
_qrPollIntervalMs += 500;
|
if (res['error'] == 'authorization_pending') {
|
||||||
timer.cancel();
|
|
||||||
_startQrPolling();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (res['error'] == 'expired_token') {
|
||||||
if (res['error'] == 'authorization_pending') {
|
timer.cancel();
|
||||||
return;
|
_qrCountdownTimer?.cancel();
|
||||||
}
|
|
||||||
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 {
|
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr('msg.userfront.login.qr_expired', fallback: 'QR 세션이 만료되었습니다.'),
|
||||||
'msg.userfront.login.token_missing',
|
|
||||||
fallback: '로그인 토큰을 확인할 수 없습니다.',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
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() {
|
void _stopQrPolling() {
|
||||||
@@ -469,21 +505,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Duration redirectDelay = const Duration(seconds: 2),
|
Duration redirectDelay = const Duration(seconds: 2),
|
||||||
}) {
|
}) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final resolvedTitle = title ??
|
final resolvedTitle =
|
||||||
tr(
|
title ?? tr('ui.userfront.login.verification.title', fallback: '승인 완료');
|
||||||
'ui.userfront.login.verification.title',
|
final resolvedPageTitle =
|
||||||
fallback: '승인 완료',
|
pageTitle ??
|
||||||
);
|
tr('ui.userfront.login.verification.page_title', fallback: '로그인 승인');
|
||||||
final resolvedPageTitle = pageTitle ??
|
final resolvedActionLabel =
|
||||||
tr(
|
actionLabel ??
|
||||||
'ui.userfront.login.verification.page_title',
|
tr('ui.userfront.login.verification.action_label', fallback: '확인');
|
||||||
fallback: '로그인 승인',
|
|
||||||
);
|
|
||||||
final resolvedActionLabel = actionLabel ??
|
|
||||||
tr(
|
|
||||||
'ui.userfront.login.verification.action_label',
|
|
||||||
fallback: '확인',
|
|
||||||
);
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_verificationApproved = true;
|
_verificationApproved = true;
|
||||||
_verificationMessage = message;
|
_verificationMessage = message;
|
||||||
@@ -507,11 +536,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_verificationTitle,
|
_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),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -527,7 +564,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
|
final hasLocalSession =
|
||||||
|
AuthTokenStore.getToken() != null ||
|
||||||
|
AuthTokenStore.usesCookie();
|
||||||
final target = hasLocalSession ? '/' : '/signin';
|
final target = hasLocalSession ? '/' : '/signin';
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -569,10 +608,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -585,18 +621,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
||||||
@@ -612,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(' ', '+');
|
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
||||||
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
|
debugPrint(
|
||||||
|
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
||||||
|
);
|
||||||
final approvedMessage = tr(
|
final approvedMessage = tr(
|
||||||
'msg.userfront.login.verification.approved',
|
'msg.userfront.login.verification.approved',
|
||||||
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
|
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
|
||||||
@@ -636,16 +672,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
final jwt = res['sessionJwt'] ?? res['token'];
|
final jwt = res['sessionJwt'] ?? res['token'];
|
||||||
final status = res['status']?.toString();
|
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 hasLocalSession = await _hasValidLocalSession();
|
||||||
final actionPath = hasLocalSession ? '/' : '/signin';
|
final actionPath = hasLocalSession ? '/' : '/signin';
|
||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -659,18 +694,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_verificationOnly) {
|
if (_verificationOnly) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
linkLoginMessage,
|
linkLoginMessage,
|
||||||
title: tr(
|
title: tr('ui.userfront.login.link.title', fallback: '링크 로그인 완료'),
|
||||||
'ui.userfront.login.link.title',
|
|
||||||
fallback: '링크 로그인 완료',
|
|
||||||
),
|
|
||||||
pageTitle: tr(
|
pageTitle: tr(
|
||||||
'ui.userfront.login.link.page_title',
|
'ui.userfront.login.link.page_title',
|
||||||
fallback: '링크 로그인',
|
fallback: '링크 로그인',
|
||||||
@@ -686,13 +715,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_verificationOnly && mounted) {
|
if (_verificationOnly && mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
debugPrint(
|
||||||
|
"[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e",
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
@@ -730,10 +758,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (jwt == null && status == 'approved') {
|
if (jwt == null && status == 'approved') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -747,10 +772,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_verificationOnly) {
|
if (_verificationOnly) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||||
@@ -758,10 +780,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_verificationOnly && mounted) {
|
if (_verificationOnly && mounted) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||||
approvedMessage,
|
|
||||||
actionPath: actionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||||
@@ -819,7 +838,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge);
|
final res = await AuthProxyService.loginWithPassword(
|
||||||
|
loginId,
|
||||||
|
password,
|
||||||
|
loginChallenge: _loginChallenge,
|
||||||
|
);
|
||||||
final jwt = res['sessionJwt'];
|
final jwt = res['sessionJwt'];
|
||||||
final provider = res['provider'] as String?;
|
final provider = res['provider'] as String?;
|
||||||
final redirectTo = res['redirectTo'] as String?;
|
final redirectTo = res['redirectTo'] as String?;
|
||||||
@@ -843,9 +866,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.password.failed',
|
'msg.userfront.login.password.failed',
|
||||||
fallback: '로그인 실패: {{error}}',
|
fallback: '로그인 실패: {{error}}',
|
||||||
params: {
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||||
'error': e.toString().replaceFirst('Exception: ', ''),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -883,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 {
|
try {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
builder: (context) =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +930,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final interval = initResponse['interval'];
|
final interval = initResponse['interval'];
|
||||||
final resendAfter = initResponse['resendAfter'];
|
final resendAfter = initResponse['resendAfter'];
|
||||||
final expiresIn = initResponse['expiresIn'];
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -957,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;
|
int attempts = 0;
|
||||||
const maxAttempts = 60;
|
const maxAttempts = 60;
|
||||||
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
||||||
@@ -1030,10 +1061,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr('msg.userfront.login.link_timeout', fallback: '로그인 요청 시간이 초과되었습니다.'),
|
||||||
'msg.userfront.login.link_timeout',
|
|
||||||
fallback: '로그인 요청 시간이 초과되었습니다.',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1121,16 +1149,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (WebAuthIntegration.isPopup()) {
|
if (WebAuthIntegration.isPopup() ||
|
||||||
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||||
|
debugPrint(
|
||||||
|
"[Auth] External integration detected (popup or redirect). Notifying...",
|
||||||
|
);
|
||||||
WebAuthIntegration.sendLoginSuccess(token);
|
WebAuthIntegration.sendLoginSuccess(token);
|
||||||
} else {
|
return;
|
||||||
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
|
||||||
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
|
|
||||||
final target = "$_redirectUrl?token=$token";
|
|
||||||
launchUrlString(target, webOnlyWindowName: '_self');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("[Auth] Login success. Navigating to root.");
|
debugPrint("[Auth] Login success. Navigating to root.");
|
||||||
@@ -1213,7 +1238,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (_drySendEnabled) ...[
|
if (_drySendEnabled) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFF3CD),
|
color: const Color(0xFFFFF3CD),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -1221,13 +1249,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Color(0xFF8A6D3B),
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.dry_send',
|
'msg.userfront.login.dry_send',
|
||||||
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
fallback:
|
||||||
|
'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF8A6D3B),
|
color: Color(0xFF8A6D3B),
|
||||||
@@ -1283,7 +1315,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
fallback: '이메일 또는 휴대폰 번호',
|
fallback: '이메일 또는 휴대폰 번호',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handlePasswordLogin(),
|
onSubmitted: (_) => _handlePasswordLogin(),
|
||||||
),
|
),
|
||||||
@@ -1297,7 +1331,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
fallback: '비밀번호',
|
fallback: '비밀번호',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handlePasswordLogin(),
|
onSubmitted: (_) => _handlePasswordLogin(),
|
||||||
),
|
),
|
||||||
@@ -1308,7 +1344,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr('ui.userfront.login.action.submit', fallback: '로그인'),
|
tr(
|
||||||
|
'ui.userfront.login.action.submit',
|
||||||
|
fallback: '로그인',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1329,7 +1368,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
hintText: '',
|
hintText: '',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handleLinkLogin(),
|
onSubmitted: (_) => _handleLinkLogin(),
|
||||||
),
|
),
|
||||||
@@ -1352,7 +1393,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'msg.userfront.login.link.helper',
|
'msg.userfront.login.link.helper',
|
||||||
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
|
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1360,9 +1404,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Text(
|
Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.short_code_help',
|
'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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1371,16 +1419,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _shortCodePrefixController,
|
controller:
|
||||||
textCapitalization: TextCapitalization.characters,
|
_shortCodePrefixController,
|
||||||
|
textCapitalization:
|
||||||
|
TextCapitalization.characters,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.login.short_code.prefix',
|
'ui.userfront.login.short_code.prefix',
|
||||||
fallback: '영문 2자리',
|
fallback: '영문 2자리',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border:
|
||||||
|
const OutlineInputBorder(),
|
||||||
hintText: 'AB',
|
hintText: 'AB',
|
||||||
hintStyle: const TextStyle(color: Colors.grey),
|
hintStyle: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLength: 2,
|
maxLength: 2,
|
||||||
),
|
),
|
||||||
@@ -1389,22 +1442,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 4,
|
flex: 4,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _shortCodeDigitsController,
|
controller:
|
||||||
|
_shortCodeDigitsController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr(
|
||||||
'ui.userfront.login.short_code.digits',
|
'ui.userfront.login.short_code.digits',
|
||||||
fallback: '숫자 6자리',
|
fallback: '숫자 6자리',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border:
|
||||||
|
const OutlineInputBorder(),
|
||||||
hintText: '345678',
|
hintText: '345678',
|
||||||
hintStyle: const TextStyle(color: Colors.grey),
|
hintStyle: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
suffixText: _linkExpireSeconds > 0
|
suffixText: _linkExpireSeconds > 0
|
||||||
? tr(
|
? tr(
|
||||||
'ui.userfront.login.short_code.expire_time',
|
'ui.userfront.login.short_code.expire_time',
|
||||||
fallback: '유효시간 {{time}}',
|
fallback: '유효시간 {{time}}',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkExpireSeconds),
|
'time': _formatTime(
|
||||||
|
_linkExpireSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -1417,13 +1476,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
|
final prefix =
|
||||||
final digits = _shortCodeDigitsController.text.trim();
|
_shortCodePrefixController.text
|
||||||
if (prefix.length != 2 || digits.length != 6) {
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
final digits =
|
||||||
|
_shortCodeDigitsController.text
|
||||||
|
.trim();
|
||||||
|
if (prefix.length != 2 ||
|
||||||
|
digits.length != 6) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.short_code.invalid',
|
'msg.userfront.login.short_code.invalid',
|
||||||
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
|
fallback:
|
||||||
|
'문자 2개와 숫자 6자리를 입력해 주세요.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -1447,27 +1513,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_showInfo(
|
_showInfo(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.resend_wait',
|
'msg.userfront.login.link.resend_wait',
|
||||||
fallback: '재발송은 {{time}} 후 가능합니다.',
|
fallback:
|
||||||
|
'재발송은 {{time}} 후 가능합니다.',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
final loginId =
|
||||||
|
_lastLinkLoginId ??
|
||||||
|
_linkIdController.text.trim();
|
||||||
if (loginId.isEmpty) {
|
if (loginId.isEmpty) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.missing_login_id',
|
'msg.userfront.login.link.missing_login_id',
|
||||||
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
|
fallback:
|
||||||
|
'이메일 또는 휴대폰 번호를 입력해 주세요.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_startEnchantedFlow(
|
_startEnchantedFlow(
|
||||||
loginId,
|
loginId,
|
||||||
isEmail: _lastLinkIsEmail || loginId.contains('@'),
|
isEmail:
|
||||||
|
_lastLinkIsEmail ||
|
||||||
|
loginId.contains('@'),
|
||||||
codeOnly: false,
|
codeOnly: false,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1477,7 +1551,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'ui.userfront.login.link.resend_with_time',
|
'ui.userfront.login.link.resend_with_time',
|
||||||
fallback: '재발송 ({{time}})',
|
fallback: '재발송 ({{time}})',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: tr(
|
: tr(
|
||||||
@@ -1494,15 +1570,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_showInfo(
|
_showInfo(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.login.link.resend_wait',
|
'msg.userfront.login.link.resend_wait',
|
||||||
fallback: '재발송은 {{time}} 후 가능합니다.',
|
fallback:
|
||||||
|
'재발송은 {{time}} 후 가능합니다.',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
final loginId =
|
||||||
|
_lastLinkLoginId ??
|
||||||
|
_linkIdController.text.trim();
|
||||||
if (loginId.isEmpty) {
|
if (loginId.isEmpty) {
|
||||||
_showError(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
@@ -1523,7 +1604,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'ui.userfront.login.link.code_only',
|
'ui.userfront.login.link.code_only',
|
||||||
fallback: '코드만 받기({{time}})',
|
fallback: '코드만 받기({{time}})',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_linkResendSeconds),
|
'time': _formatTime(
|
||||||
|
_linkResendSeconds,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1542,13 +1625,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const CircularProgressIndicator()
|
const CircularProgressIndicator()
|
||||||
else if (_qrImageBase64 != null)
|
else if (_qrImageBase64 != null)
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: QrImageView(
|
child: QrImageView(
|
||||||
data: _qrImageBase64!,
|
data: _qrImageBase64!,
|
||||||
@@ -1559,20 +1647,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
_qrRemainingSeconds > 0
|
_qrRemainingSeconds > 0
|
||||||
? tr(
|
? tr(
|
||||||
'ui.userfront.login.qr.remaining',
|
'ui.userfront.login.qr.remaining',
|
||||||
fallback: '남은 시간: {{time}}',
|
fallback: '남은 시간: {{time}}',
|
||||||
params: {
|
params: {
|
||||||
'time': _formatTime(_qrRemainingSeconds),
|
'time': _formatTime(
|
||||||
},
|
_qrRemainingSeconds,
|
||||||
)
|
),
|
||||||
: tr(
|
},
|
||||||
'ui.userfront.login.qr.expired',
|
)
|
||||||
fallback: 'QR 코드 만료됨',
|
: tr(
|
||||||
),
|
'ui.userfront.login.qr.expired',
|
||||||
|
fallback: 'QR 코드 만료됨',
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
|
color: _qrRemainingSeconds > 30
|
||||||
|
? Colors.blue
|
||||||
|
: Colors.red,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1583,7 +1675,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
fallback: '모바일 앱으로 스캔하세요',
|
fallback: '모바일 앱으로 스캔하세요',
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _startQrFlow,
|
onPressed: _startQrFlow,
|
||||||
@@ -1629,7 +1724,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
'msg.userfront.login.no_account',
|
'msg.userfront.login.no_account',
|
||||||
fallback: '계정이 없으신가요?',
|
fallback: '계정이 없으신가요?',
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.push('/signup'),
|
onPressed: () => context.push('/signup'),
|
||||||
|
|||||||
@@ -14,18 +14,22 @@ class LoginSuccessScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
|
tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
|
tr(
|
||||||
|
'msg.userfront.login_success.subtitle',
|
||||||
|
fallback: '성공적으로 로그인되었습니다.',
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||||
),
|
),
|
||||||
@@ -38,12 +42,18 @@ class LoginSuccessScreen extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
icon: const Icon(Icons.camera_alt, size: 28),
|
icon: const Icon(Icons.camera_alt, size: 28),
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
|
tr(
|
||||||
|
'ui.userfront.login_success.qr',
|
||||||
|
fallback: 'QR 인증 (카메라 켜기)',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
|
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
|
||||||
backgroundColor: Colors.blue.shade700,
|
backgroundColor: Colors.blue.shade700,
|
||||||
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -226,7 +226,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color),
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
@@ -268,7 +272,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
onDetect: _onDetect,
|
onDetect: _onDetect,
|
||||||
errorBuilder: (context, error) {
|
errorBuilder: (context, error) {
|
||||||
final isPermissionDenied = error.errorCode ==
|
final isPermissionDenied =
|
||||||
|
error.errorCode ==
|
||||||
MobileScannerErrorCode.permissionDenied;
|
MobileScannerErrorCode.permissionDenied;
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -295,7 +300,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
|||||||
: _requestCameraPermission,
|
: _requestCameraPermission,
|
||||||
child: Text(
|
child: Text(
|
||||||
_isRequestingCamera
|
_isRequestingCamera
|
||||||
? tr('ui.common.requesting', fallback: '요청 중...')
|
? tr(
|
||||||
|
'ui.common.requesting',
|
||||||
|
fallback: '요청 중...',
|
||||||
|
)
|
||||||
: tr(
|
: tr(
|
||||||
'ui.userfront.qr.request_permission',
|
'ui.userfront.qr.request_permission',
|
||||||
fallback: '카메라 권한 요청하기',
|
fallback: '카메라 권한 요청하기',
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
final TextEditingController _confirmPasswordController = TextEditingController();
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _loginId;
|
String? _loginId;
|
||||||
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
|
|
||||||
// 2. Fallback to URI query parameter if not available via router
|
// 2. Fallback to URI query parameter if not available via router
|
||||||
if (_loginId == null || _loginId!.isEmpty) {
|
if (_loginId == null || _loginId!.isEmpty) {
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
_loginId = uri.queryParameters['loginId'];
|
_loginId = uri.queryParameters['loginId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰도 함께 읽어놓는다.
|
// 토큰도 함께 읽어놓는다.
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
_token = uri.queryParameters['token'];
|
_token = uri.queryParameters['token'];
|
||||||
|
|
||||||
_loadPolicy();
|
_loadPolicy();
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
|
|
||||||
Future<void> _handlePasswordReset() async {
|
Future<void> _handlePasswordReset() async {
|
||||||
if (_formKey.currentState?.validate() != true) return;
|
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(
|
_showError(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.reset.invalid_link',
|
'msg.userfront.reset.invalid_link',
|
||||||
@@ -163,9 +165,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresNumber) {
|
if (requiresNumber) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
|
||||||
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresSymbol) {
|
if (requiresSymbol) {
|
||||||
parts.add(
|
parts.add(
|
||||||
@@ -180,16 +180,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
|
||||||
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
|
child:
|
||||||
|
(_loginId == null || _loginId!.isEmpty) &&
|
||||||
|
(_token == null || _token!.isEmpty)
|
||||||
? _buildInvalidTokenView()
|
? _buildInvalidTokenView()
|
||||||
: Form(
|
: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@@ -227,7 +227,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
_isPasswordObscured
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -244,7 +246,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
fallback: '비밀번호를 입력해주세요.',
|
fallback: '비밀번호를 입력해주세요.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
final minLength =
|
||||||
|
(_policy?['minLength'] as int?) ?? 12;
|
||||||
if (val.length < minLength) {
|
if (val.length < minLength) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.min_length',
|
'msg.userfront.reset.error.min_length',
|
||||||
@@ -262,7 +265,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
if (hasNumber) typeCount++;
|
if (hasNumber) typeCount++;
|
||||||
if (hasSymbol) typeCount++;
|
if (hasSymbol) typeCount++;
|
||||||
|
|
||||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
final minTypes =
|
||||||
|
(_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||||
if (minTypes > 0 && typeCount < minTypes) {
|
if (minTypes > 0 && typeCount < minTypes) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.min_types',
|
'msg.userfront.reset.error.min_types',
|
||||||
@@ -290,7 +294,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
|
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
||||||
|
!hasSymbol) {
|
||||||
return tr(
|
return tr(
|
||||||
'msg.userfront.reset.error.symbol',
|
'msg.userfront.reset.error.symbol',
|
||||||
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
|
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
|
||||||
@@ -312,11 +317,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
_isConfirmPasswordObscured
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
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 Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.reset.invalid_title',
|
tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
|
||||||
fallback: '유효하지 않은 링크입니다.'),
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final email = _emailController.text.trim();
|
final email = _emailController.text.trim();
|
||||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||||
if (!emailRegex.hasMatch(email)) {
|
if (!emailRegex.hasMatch(email)) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.invalid',
|
() => _emailError = tr(
|
||||||
fallback: '유효한 이메일 형식이 아닙니다.',
|
'msg.userfront.signup.email.invalid',
|
||||||
));
|
fallback: '유효한 이메일 형식이 아닙니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() { _isLoading = true; _emailError = null; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_emailError = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
final available = await AuthProxyService.checkEmailAvailability(email);
|
final available = await AuthProxyService.checkEmailAvailability(email);
|
||||||
if (!available) {
|
if (!available) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.duplicate',
|
() => _emailError = tr(
|
||||||
fallback: '이미 가입된 이메일입니다.',
|
'msg.userfront.signup.email.duplicate',
|
||||||
));
|
fallback: '이미 가입된 이메일입니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await AuthProxyService.sendSignupCode(email, 'email');
|
await AuthProxyService.sendSignupCode(email, 'email');
|
||||||
_startTimer('email');
|
_startTimer('email');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.send_failed',
|
() => _emailError = tr(
|
||||||
fallback: '발송 실패: {{error}}',
|
'msg.userfront.signup.email.send_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '발송 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _emailCodeController.text.trim();
|
final code = _emailCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
|
final success = await AuthProxyService.verifySignupCode(
|
||||||
|
_emailController.text.trim(),
|
||||||
|
'email',
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isEmailVerified = true;
|
_isEmailVerified = true;
|
||||||
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_emailError = null;
|
_emailError = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.code_mismatch',
|
() => _emailError = tr(
|
||||||
fallback: '인증코드가 일치하지 않습니다.',
|
'msg.userfront.signup.email.code_mismatch',
|
||||||
));
|
fallback: '인증코드가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _emailError = tr(
|
setState(
|
||||||
'msg.userfront.signup.email.verify_failed',
|
() => _emailError = tr(
|
||||||
fallback: '인증 실패: {{error}}',
|
'msg.userfront.signup.email.verify_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '인증 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendPhoneCode() async {
|
Future<void> _sendPhoneCode() async {
|
||||||
final phone = _phoneController.text.trim();
|
final phone = _phoneController.text.trim();
|
||||||
if (phone.isEmpty) return;
|
if (phone.isEmpty) return;
|
||||||
setState(() { _isLoading = true; _phoneError = null; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_phoneError = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.sendSignupCode(phone, 'phone');
|
await AuthProxyService.sendSignupCode(phone, 'phone');
|
||||||
_startTimer('phone');
|
_startTimer('phone');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.send_failed',
|
() => _phoneError = tr(
|
||||||
fallback: '발송 실패: {{error}}',
|
'msg.userfront.signup.phone.send_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '발송 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final code = _phoneCodeController.text.trim();
|
final code = _phoneCodeController.text.trim();
|
||||||
if (code.length != 6) return;
|
if (code.length != 6) return;
|
||||||
try {
|
try {
|
||||||
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
|
final success = await AuthProxyService.verifySignupCode(
|
||||||
|
_phoneController.text.trim(),
|
||||||
|
'phone',
|
||||||
|
code,
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPhoneVerified = true;
|
_isPhoneVerified = true;
|
||||||
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
_phoneError = null;
|
_phoneError = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.code_mismatch',
|
() => _phoneError = tr(
|
||||||
fallback: '인증코드가 일치하지 않습니다.',
|
'msg.userfront.signup.phone.code_mismatch',
|
||||||
));
|
fallback: '인증코드가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _phoneError = tr(
|
setState(
|
||||||
'msg.userfront.signup.phone.verify_failed',
|
() => _phoneError = tr(
|
||||||
fallback: '인증 실패: {{error}}',
|
'msg.userfront.signup.phone.verify_failed',
|
||||||
params: {'error': e.toString()},
|
fallback: '인증 실패: {{error}}',
|
||||||
));
|
params: {'error': e.toString()},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSignup() async {
|
Future<void> _handleSignup() async {
|
||||||
if (_passwordController.text != _confirmPasswordController.text) {
|
if (_passwordController.text != _confirmPasswordController.text) {
|
||||||
setState(() => _confirmPasswordError = tr(
|
setState(
|
||||||
'msg.userfront.signup.password.mismatch',
|
() => _confirmPasswordError = tr(
|
||||||
fallback: '비밀번호가 일치하지 않습니다.',
|
'msg.userfront.signup.password.mismatch',
|
||||||
));
|
fallback: '비밀번호가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
phone: _phoneController.text.trim(),
|
phone: _phoneController.text.trim(),
|
||||||
affiliationType: _affiliationType,
|
affiliationType: _affiliationType,
|
||||||
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
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,
|
termsAccepted: true,
|
||||||
);
|
);
|
||||||
if (mounted) _showSuccessDialog();
|
if (mounted) _showSuccessDialog();
|
||||||
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 12,
|
radius: 12,
|
||||||
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
|
backgroundColor: isDone
|
||||||
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
|
? 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),
|
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(
|
child: CheckboxListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
tr(
|
tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
|
||||||
'ui.userfront.signup.agreement.all',
|
|
||||||
fallback: '모두 동의합니다',
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
value: _termsAccepted && _privacyAccepted,
|
value: _termsAccepted && _privacyAccepted,
|
||||||
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
title: Text(title,
|
title: Text(
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
title,
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Text(
|
child: Text(
|
||||||
content,
|
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(
|
static String get _tosText => tr(
|
||||||
'msg.userfront.signup.tos_full',
|
'msg.userfront.signup.tos_full',
|
||||||
fallback: """
|
fallback: """
|
||||||
바론 소프트웨어 이용약관
|
바론 소프트웨어 이용약관
|
||||||
|
|
||||||
제1장 총칙
|
제1장 총칙
|
||||||
@@ -589,11 +643,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
부칙
|
부칙
|
||||||
본 약관은 2024년 10월 1일부터 시행됩니다.
|
본 약관은 2024년 10월 1일부터 시행됩니다.
|
||||||
""",
|
""",
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get _privacyText => tr(
|
static String get _privacyText => tr(
|
||||||
'msg.userfront.signup.privacy_full',
|
'msg.userfront.signup.privacy_full',
|
||||||
fallback: """
|
fallback: """
|
||||||
개인정보 수집 및 이용 동의
|
개인정보 수집 및 이용 동의
|
||||||
|
|
||||||
바론서비스 개인정보처리방침
|
바론서비스 개인정보처리방침
|
||||||
@@ -702,7 +756,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
제8조 (기타)
|
제8조 (기타)
|
||||||
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
||||||
""",
|
""",
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildStepAuth() {
|
Widget _buildStepAuth() {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
// 가족사 이메일 안내 문구
|
// 가족사 이메일 안내 문구
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
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',
|
'msg.userfront.signup.auth.affiliate_notice',
|
||||||
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
|
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
|
||||||
),
|
),
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
|
onPressed: (_isEmailVerified || _isLoading)
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
? null
|
||||||
|
: _sendEmailCode,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_emailSeconds > 0
|
_emailSeconds > 0
|
||||||
? tr('ui.common.resend', fallback: '재발송')
|
? tr('ui.common.resend', fallback: '재발송')
|
||||||
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
inputFormatters: [
|
||||||
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val.length == 6) _verifyEmailCode();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_isEmailVerified)
|
if (_isEmailVerified)
|
||||||
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 55,
|
height: 55,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
|
onPressed: (_isPhoneVerified || _isLoading)
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
? null
|
||||||
|
: _sendPhoneCode,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_phoneSeconds > 0
|
_phoneSeconds > 0
|
||||||
? tr('ui.common.resend', fallback: '재발송')
|
? tr('ui.common.resend', fallback: '재발송')
|
||||||
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
inputFormatters: [
|
||||||
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(6),
|
||||||
|
],
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val.length == 6) _verifyPhoneCode();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_isPhoneVerified)
|
if (_isPhoneVerified)
|
||||||
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => setState(() {}),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: tr(
|
labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
|
||||||
'ui.userfront.signup.profile.name',
|
|
||||||
fallback: '이름',
|
|
||||||
),
|
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'GENERAL',
|
value: 'GENERAL',
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('domain.affiliation.general', fallback: '일반 사용자'),
|
||||||
'domain.affiliation.general',
|
|
||||||
fallback: '일반 사용자',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'AFFILIATE',
|
value: 'AFFILIATE',
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
|
||||||
'domain.affiliation.affiliate',
|
|
||||||
fallback: '가족사 임직원',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
items: [
|
items: [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'HANMAC',
|
value: 'HANMAC',
|
||||||
child: Text(
|
child: Text(tr('domain.company.hanmac', fallback: '한맥')),
|
||||||
tr('domain.company.hanmac', fallback: '한맥'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'SAMAN',
|
value: 'SAMAN',
|
||||||
child: Text(
|
child: Text(tr('domain.company.saman', fallback: '삼안')),
|
||||||
tr('domain.company.saman', fallback: '삼안'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'PTC',
|
value: 'PTC',
|
||||||
child: Text(
|
child: Text(tr('domain.company.ptc', fallback: 'PTC')),
|
||||||
tr('domain.company.ptc', fallback: 'PTC'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'JANGHEON',
|
value: 'JANGHEON',
|
||||||
child: Text(
|
child: Text(tr('domain.company.jangheon', fallback: '장헌')),
|
||||||
tr('domain.company.jangheon', fallback: '장헌'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'BARON',
|
value: 'BARON',
|
||||||
child: Text(
|
child: Text(tr('domain.company.baron', fallback: '바론')),
|
||||||
tr('domain.company.baron', fallback: '바론'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: 'HALLA',
|
value: 'HALLA',
|
||||||
child: Text(
|
child: Text(tr('domain.company.halla', fallback: '한라')),
|
||||||
tr('domain.company.halla', fallback: '한라'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: _isAffiliateEmail
|
onChanged: _isAffiliateEmail
|
||||||
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
'ui.userfront.signup.profile.department_optional',
|
'ui.userfront.signup.profile.department_optional',
|
||||||
fallback: '소속 정보 (선택)',
|
fallback: '소속 정보 (선택)',
|
||||||
),
|
),
|
||||||
border: const OutlineInputBorder()
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requiresUpper) {
|
if (requiresUpper) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.uppercase',
|
|
||||||
fallback: '대문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresLower) {
|
if (requiresLower) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.lowercase',
|
|
||||||
fallback: '소문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresNumber) {
|
if (requiresNumber) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.number',
|
|
||||||
fallback: '숫자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (requiresSymbol) {
|
if (requiresSymbol) {
|
||||||
parts.add(
|
parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
|
||||||
tr(
|
|
||||||
'msg.userfront.signup.policy.symbol',
|
|
||||||
fallback: '특수문자',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tr(
|
return tr(
|
||||||
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
// 비밀번호 정책 안내 박스
|
// 비밀번호 정책 안내 박스
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.security, size: 18, color: Colors.blue),
|
const Icon(Icons.security, size: 18, color: Colors.blue),
|
||||||
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_buildPolicyDescription(),
|
_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)
|
if (requiresNumber)
|
||||||
_cryptoCheck(
|
_cryptoCheck(
|
||||||
tr(
|
tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
|
||||||
'msg.userfront.signup.password.rule.number',
|
|
||||||
fallback: '숫자',
|
|
||||||
),
|
|
||||||
hasDigit,
|
hasDigit,
|
||||||
),
|
),
|
||||||
if (requiresSymbol)
|
if (requiresSymbol)
|
||||||
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
bool canGoNext = false;
|
bool canGoNext = false;
|
||||||
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
|
if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
|
||||||
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
|
canGoNext = true;
|
||||||
|
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
|
||||||
|
canGoNext = true;
|
||||||
if (_currentStep == 3) {
|
if (_currentStep == 3) {
|
||||||
final nameOk = _nameController.text.trim().isNotEmpty;
|
final nameOk = _nameController.text.trim().isNotEmpty;
|
||||||
if (_affiliationType == 'GENERAL') {
|
if (_affiliationType == 'GENERAL') {
|
||||||
@@ -1314,10 +1372,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: _currentStep == 1
|
child: _currentStep == 1
|
||||||
? _buildStepAgreement()
|
? _buildStepAgreement()
|
||||||
: (_currentStep == 2
|
: (_currentStep == 2
|
||||||
? _buildStepAuth()
|
? _buildStepAuth()
|
||||||
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
|
: (_currentStep == 3
|
||||||
|
? _buildStepInfo()
|
||||||
|
: _buildStepPassword())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
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(
|
child: Text(
|
||||||
tr('ui.common.prev', fallback: '이전'),
|
tr('ui.common.prev', fallback: '이전'),
|
||||||
style: const TextStyle(color: Colors.black),
|
style: const TextStyle(color: Colors.black),
|
||||||
@@ -1341,19 +1404,34 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: _currentStep < 4
|
onPressed: _currentStep < 4
|
||||||
? (canGoNext ? () => setState(() => _currentStep++) : null)
|
? (canGoNext
|
||||||
: (_isLoading ? null : _handleSignup),
|
? () => setState(() => _currentStep++)
|
||||||
|
: null)
|
||||||
|
: (_isLoading ? null : _handleSignup),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(55),
|
minimumSize: const Size.fromHeight(55),
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
? const SizedBox(
|
||||||
: Text(
|
height: 20,
|
||||||
_currentStep < 4
|
width: 20,
|
||||||
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
|
child: CircularProgressIndicator(
|
||||||
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
|
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');
|
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||||
|
|
||||||
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
||||||
final queryParameters = <String, String>{
|
final queryParameters = <String, String>{'limit': '20'};
|
||||||
'limit': '20',
|
|
||||||
};
|
|
||||||
if (cursor != null && cursor.isNotEmpty) {
|
if (cursor != null && cursor.isNotEmpty) {
|
||||||
queryParameters['cursor'] = cursor;
|
queryParameters['cursor'] = cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline')
|
final url = Uri.parse(
|
||||||
.replace(queryParameters: queryParameters);
|
'$_baseUrl/api/v1/audit/auth/timeline',
|
||||||
|
).replace(queryParameters: queryParameters);
|
||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -60,10 +57,6 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
|
||||||
|
|
||||||
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
|
||||||
@@ -86,11 +79,11 @@ class AuthTimelineState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AuthTimelineState.initial()
|
const AuthTimelineState.initial()
|
||||||
: items = const [],
|
: items = const [],
|
||||||
nextCursor = null,
|
nextCursor = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
AuthTimelineState copyWith({
|
AuthTimelineState copyWith({
|
||||||
List<AuditLogEntry>? items,
|
List<AuditLogEntry>? items,
|
||||||
@@ -188,6 +181,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
final authTimelineProvider =
|
||||||
AuthTimelineNotifier.new,
|
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
|
||||||
);
|
AuthTimelineNotifier.new,
|
||||||
|
);
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
|||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -106,6 +104,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
final linkedRpsProvider =
|
||||||
return LinkedRpsNotifier();
|
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
|
||||||
});
|
return LinkedRpsNotifier();
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.dashboard.revoke.confirm',
|
'msg.userfront.dashboard.revoke.confirm',
|
||||||
fallback:
|
fallback: '{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
|
||||||
'{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
|
|
||||||
params: {'app': appName},
|
params: {'app': appName},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -81,8 +80,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child:
|
child: Text(
|
||||||
Text(tr('ui.userfront.dashboard.revoke.confirm_button', fallback: '해지하기')),
|
tr(
|
||||||
|
'ui.userfront.dashboard.revoke.confirm_button',
|
||||||
|
fallback: '해지하기',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -158,31 +161,45 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.dashboard.scopes.title',
|
tr(
|
||||||
fallback: '권한 (Scopes)'),
|
'ui.userfront.dashboard.scopes.title',
|
||||||
|
fallback: '권한 (Scopes)',
|
||||||
|
),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (item.scopes.isEmpty)
|
if (item.scopes.isEmpty)
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.dashboard.scopes.empty',
|
tr(
|
||||||
fallback: '요청된 권한이 없습니다.'),
|
'msg.userfront.dashboard.scopes.empty',
|
||||||
|
fallback: '요청된 권한이 없습니다.',
|
||||||
|
),
|
||||||
style: const TextStyle(color: Colors.grey),
|
style: const TextStyle(color: Colors.grey),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
children: item.scopes.map((s) => Chip(
|
children: item.scopes
|
||||||
label: Text(s, style: const TextStyle(fontSize: 12)),
|
.map(
|
||||||
visualDensity: VisualDensity.compact,
|
(s) => Chip(
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
label: Text(
|
||||||
)).toList(),
|
s,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
tr('ui.userfront.dashboard.status_history',
|
tr(
|
||||||
fallback: '상태 이력'),
|
'ui.userfront.dashboard.status_history',
|
||||||
|
fallback: '상태 이력',
|
||||||
|
),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -200,10 +217,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final statusLabel = item.status == 'active'
|
final statusLabel = item.status == 'active'
|
||||||
? tr('ui.common.status.active',
|
? tr('ui.common.status.active', fallback: '활성')
|
||||||
fallback: '활성')
|
: tr(
|
||||||
: tr('ui.userfront.dashboard.status.revoked',
|
'ui.userfront.dashboard.status.revoked',
|
||||||
fallback: '해지됨');
|
fallback: '해지됨',
|
||||||
|
);
|
||||||
return Text(
|
return Text(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.dashboard.current_status',
|
'msg.userfront.dashboard.current_status',
|
||||||
@@ -242,8 +260,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.home_outlined),
|
leading: const Icon(Icons.home_outlined),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
|
||||||
Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
|
|
||||||
selected: true,
|
selected: true,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
@@ -254,8 +271,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.person_outline),
|
leading: const Icon(Icons.person_outline),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
|
||||||
Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -265,8 +281,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.qr_code_scanner),
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
|
||||||
Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -277,8 +292,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.logout),
|
leading: const Icon(Icons.logout),
|
||||||
title:
|
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
|
||||||
Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (closeOnTap) {
|
if (closeOnTap) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -316,21 +330,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
|
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
|
||||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||||
final queryParameters = <String, String>{
|
final queryParameters = <String, String>{'limit': '20'};
|
||||||
'limit': '20',
|
|
||||||
};
|
|
||||||
if (cursor != null && cursor.isNotEmpty) {
|
if (cursor != null && cursor.isNotEmpty) {
|
||||||
queryParameters['cursor'] = cursor;
|
queryParameters['cursor'] = cursor;
|
||||||
}
|
}
|
||||||
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline')
|
final url = Uri.parse(
|
||||||
.replace(queryParameters: queryParameters);
|
'$baseUrl/api/v1/audit/auth/timeline',
|
||||||
|
).replace(queryParameters: queryParameters);
|
||||||
final useCookie = AuthTokenStore.usesCookie();
|
final useCookie = AuthTokenStore.usesCookie();
|
||||||
final token = AuthTokenStore.getToken();
|
final token = AuthTokenStore.getToken();
|
||||||
|
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -401,11 +412,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
if (parts.length != 3) {
|
if (parts.length != 3) {
|
||||||
return null;
|
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 data = json.decode(payload) as Map<String, dynamic>;
|
||||||
final iatValue = data['iat'] ?? data['auth_time'];
|
final iatValue = data['iat'] ?? data['auth_time'];
|
||||||
if (iatValue is num) {
|
if (iatValue is num) {
|
||||||
return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal();
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
iatValue.toInt() * 1000,
|
||||||
|
).toLocal();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -467,9 +482,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
|
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
|
||||||
final isOidc = authMethod.contains('OIDC');
|
final isOidc = authMethod.contains('OIDC');
|
||||||
if (authMethod != 'QR' && !isOidc) {
|
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 approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
|
||||||
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
final hasApproverMeta =
|
||||||
|
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
||||||
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
||||||
return _selectableText(authMethod);
|
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.detailMap['approved_session_id'].toString()
|
||||||
: log.sessionId;
|
: log.sessionId;
|
||||||
final tooltipLabel = isOidc
|
final tooltipLabel = isOidc
|
||||||
@@ -544,8 +563,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
|
isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
||||||
decoration:
|
decoration: approvedSessionId.isEmpty
|
||||||
approvedSessionId.isEmpty ? null : TextDecoration.underline,
|
? null
|
||||||
|
: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -555,9 +575,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
|
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
|
||||||
final isOidc = authMethod.contains('OIDC');
|
final isOidc = authMethod.contains('OIDC');
|
||||||
if (authMethod != 'QR' && !isOidc) {
|
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 approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
|
||||||
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
final hasApproverMeta =
|
||||||
|
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
|
||||||
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
|
||||||
return _selectableText(
|
return _selectableText(
|
||||||
tr(
|
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.detailMap['approved_session_id'].toString()
|
||||||
: log.sessionId;
|
: log.sessionId;
|
||||||
final tooltipLabel = isOidc
|
final tooltipLabel = isOidc
|
||||||
@@ -642,7 +666,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
'msg.userfront.dashboard.auth_method',
|
'msg.userfront.dashboard.auth_method',
|
||||||
fallback: '인증수단: {{method}}',
|
fallback: '인증수단: {{method}}',
|
||||||
params: {
|
params: {
|
||||||
'method': isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
|
'method': isOidc
|
||||||
|
? authMethod
|
||||||
|
: tr('ui.common.qr', fallback: 'QR'),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -714,7 +740,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final profileState = ref.watch(profileProvider);
|
final profileState = ref.watch(profileProvider);
|
||||||
final profile = profileState.value;
|
final profile = profileState.value;
|
||||||
final timelineState = ref.watch(authTimelineProvider);
|
final timelineState = ref.watch(authTimelineProvider);
|
||||||
final userName = profile?.name ??
|
final userName =
|
||||||
|
profile?.name ??
|
||||||
profile?.email ??
|
profile?.email ??
|
||||||
profile?.phone ??
|
profile?.phone ??
|
||||||
tr('ui.userfront.profile.user_fallback', fallback: 'User');
|
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(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide)
|
if (isWide)
|
||||||
@@ -775,11 +804,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!isMobile) ...[
|
if (!isMobile) ...[
|
||||||
_buildHeaderCard(userName, department, sessionIssuedAt),
|
_buildHeaderCard(
|
||||||
|
userName,
|
||||||
|
department,
|
||||||
|
sessionIssuedAt,
|
||||||
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
],
|
],
|
||||||
_buildSectionTitle(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.sections.apps', fallback: '나의 App 현황'),
|
tr(
|
||||||
|
'ui.userfront.sections.apps',
|
||||||
|
fallback: '나의 App 현황',
|
||||||
|
),
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.sections.apps_subtitle',
|
'msg.userfront.sections.apps_subtitle',
|
||||||
fallback: '현재 연결된 앱과 최근 인증 상태입니다.',
|
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
|
final sessionLabel = issuedAt != null
|
||||||
? _formatDateTime(issuedAt)
|
? _formatDateTime(issuedAt)
|
||||||
: tr('ui.userfront.session.unknown', fallback: '알 수 없음');
|
: tr('ui.userfront.session.unknown', fallback: '알 수 없음');
|
||||||
@@ -823,7 +863,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
fallback: '안녕하세요, {{name}}님',
|
fallback: '안녕하세요, {{name}}님',
|
||||||
params: {'name': userName},
|
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),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
@@ -871,13 +915,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
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),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||||
subtitle,
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -897,7 +942,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
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',
|
'msg.userfront.dashboard.activities.empty',
|
||||||
fallback: '연동된 앱이 없습니다.',
|
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),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
@@ -959,14 +1012,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||||
final items = <_ActivityItem>[];
|
final items = <_ActivityItem>[];
|
||||||
for (final rp in linkedRps) {
|
for (final rp in linkedRps) {
|
||||||
final normalizedStatus = rp.status.toLowerCase();
|
final normalizedStatus = rp.status.toLowerCase();
|
||||||
// status가 'inactive'로 내려올 수 있으므로 이를 반영
|
// status가 'inactive'로 내려올 수 있으므로 이를 반영
|
||||||
final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == '';
|
final isActiveInApi =
|
||||||
|
normalizedStatus == 'active' || normalizedStatus == '';
|
||||||
final isRevoked = !isActiveInApi;
|
final isRevoked = !isActiveInApi;
|
||||||
|
|
||||||
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
||||||
@@ -1042,7 +1094,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
|
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
|
||||||
final double spacing = 12.0;
|
final double spacing = 12.0;
|
||||||
final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
|
final double cardWidth =
|
||||||
|
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1063,18 +1116,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: () => setState(() => _showAllActivities = !_showAllActivities),
|
onPressed: () => setState(
|
||||||
|
() => _showAllActivities = !_showAllActivities,
|
||||||
|
),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
|
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
|
color: _showAllActivities
|
||||||
|
? Colors.grey
|
||||||
|
: Colors.blueAccent,
|
||||||
),
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
_showAllActivities
|
_showAllActivities
|
||||||
? tr('ui.common.collapse', fallback: '접기')
|
? tr('ui.common.collapse', fallback: '접기')
|
||||||
: tr('ui.common.show_more', fallback: '+ 더보기'),
|
: tr('ui.common.show_more', fallback: '+ 더보기'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
|
color: _showAllActivities
|
||||||
|
? Colors.grey
|
||||||
|
: Colors.blueAccent,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1090,7 +1149,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
|
||||||
final isActive = item.status == 'active';
|
final isActive = item.status == 'active';
|
||||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
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;
|
final borderWidth = isActive ? 1.5 : 1.0;
|
||||||
|
|
||||||
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
|
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
|
||||||
@@ -1104,13 +1165,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
color: _surface,
|
color: _surface,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: borderColor, width: borderWidth),
|
border: Border.all(color: borderColor, width: borderWidth),
|
||||||
boxShadow: isActive ? [
|
boxShadow: isActive
|
||||||
BoxShadow(
|
? [
|
||||||
color: Colors.green.withValues(alpha: 13),
|
BoxShadow(
|
||||||
blurRadius: 10,
|
color: Colors.green.withValues(alpha: 13),
|
||||||
offset: const Offset(0, 4),
|
blurRadius: 10,
|
||||||
)
|
offset: const Offset(0, 4),
|
||||||
] : null,
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1120,7 +1183,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.appName,
|
item.appName,
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
@@ -1132,8 +1199,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
item.status == 'active'
|
item.status == 'active'
|
||||||
? tr('ui.common.status.active', fallback: '활성')
|
? tr('ui.common.status.active', fallback: '활성')
|
||||||
: tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨'),
|
: tr(
|
||||||
style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600),
|
'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),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.lastAuthAt,
|
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),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -1168,22 +1246,38 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke,
|
onPressed: (_isRevoking || item.isRevoked)
|
||||||
|
? null
|
||||||
|
: item.onRevoke,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent,
|
foregroundColor: item.isRevoked
|
||||||
side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5),
|
? Colors.grey
|
||||||
|
: Colors.redAccent,
|
||||||
|
side: BorderSide(
|
||||||
|
color: item.isRevoked ? Colors.grey : Colors.redAccent,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
),
|
),
|
||||||
child: _isRevoking && !item.isRevoked
|
child: _isRevoking && !item.isRevoked
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
item.isRevoked
|
item.isRevoked
|
||||||
? tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨')
|
? tr(
|
||||||
: tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'),
|
'ui.userfront.dashboard.status.revoked',
|
||||||
|
fallback: '해지됨',
|
||||||
|
)
|
||||||
|
: tr(
|
||||||
|
'ui.userfront.dashboard.revoke.title',
|
||||||
|
fallback: '연동 해지',
|
||||||
|
),
|
||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1268,7 +1362,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => ref.read(authTimelineProvider.notifier).refresh(),
|
onPressed: () =>
|
||||||
|
ref.read(authTimelineProvider.notifier).refresh(),
|
||||||
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
|
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1326,7 +1421,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
columns: [
|
columns: [
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.session_id', fallback: 'Session ID'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.session_id',
|
||||||
|
fallback: 'Session ID',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
@@ -1336,7 +1434,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.app', fallback: '애플리케이션'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.app',
|
||||||
|
fallback: '애플리케이션',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
@@ -1346,17 +1447,26 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.device', fallback: '접속환경'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.device',
|
||||||
|
fallback: '접속환경',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.auth_method', fallback: '인증수단'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.auth_method',
|
||||||
|
fallback: '인증수단',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: Text(
|
||||||
tr('ui.userfront.audit.table.result', fallback: '인증결과'),
|
tr(
|
||||||
|
'ui.userfront.audit.table.result',
|
||||||
|
fallback: '인증결과',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
@@ -1369,47 +1479,57 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final statusLabel = log.status == 'success'
|
final statusLabel = log.status == 'success'
|
||||||
? tr('ui.common.status.success', fallback: '성공')
|
? tr('ui.common.status.success', fallback: '성공')
|
||||||
: tr('ui.common.status.failure', fallback: '실패');
|
: tr('ui.common.status.failure', fallback: '실패');
|
||||||
final statusColor =
|
final statusColor = log.status == 'success'
|
||||||
log.status == 'success' ? Colors.green : Colors.redAccent;
|
? Colors.green
|
||||||
|
: Colors.redAccent;
|
||||||
final authMethod = log.authMethod.isNotEmpty
|
final authMethod = log.authMethod.isNotEmpty
|
||||||
? log.authMethod
|
? log.authMethod
|
||||||
: _authMethodLabel();
|
: _authMethodLabel();
|
||||||
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
|
final deviceLabel = _deviceLabelFromUserAgent(
|
||||||
return DataRow(cells: [
|
log.userAgent,
|
||||||
DataCell(
|
);
|
||||||
_selectableText(
|
return DataRow(
|
||||||
log.sessionId.isEmpty
|
cells: [
|
||||||
? tr('ui.common.hyphen', fallback: '-')
|
DataCell(
|
||||||
: log.sessionId,
|
_selectableText(
|
||||||
),
|
log.sessionId.isEmpty
|
||||||
),
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
DataCell(_selectableText(_formatDateTime(log.timestamp))),
|
: log.sessionId,
|
||||||
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(
|
||||||
DataCell(
|
_selectableText(_formatDateTime(log.timestamp)),
|
||||||
_selectableText(
|
|
||||||
tr('ui.userfront.audit.table.pending', fallback: '(준비중)'),
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
),
|
||||||
),
|
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(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1443,7 +1563,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _buildAppCell(
|
child: _buildAppCell(
|
||||||
log,
|
log,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_selectableText(
|
_selectableText(
|
||||||
@@ -1451,7 +1574,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
? tr('ui.common.status.success', fallback: '성공')
|
? tr('ui.common.status.success', fallback: '성공')
|
||||||
: tr('ui.common.status.failure', fallback: '실패'),
|
: tr('ui.common.status.failure', fallback: '실패'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: log.status == 'success' ? Colors.green : Colors.redAccent,
|
color: log.status == 'success'
|
||||||
|
? Colors.green
|
||||||
|
: Colors.redAccent,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1491,10 +1616,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.audit.device',
|
'msg.userfront.audit.device',
|
||||||
fallback: '접속환경: {{value}}',
|
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(
|
_selectableText(
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.audit.result',
|
'msg.userfront.audit.result',
|
||||||
@@ -1507,10 +1639,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_selectableText(
|
_selectableText(
|
||||||
tr(
|
tr('msg.userfront.audit.status', fallback: '현황: (준비중)'),
|
||||||
'msg.userfront.audit.status',
|
|
||||||
fallback: '현황: (준비중)',
|
|
||||||
),
|
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1542,7 +1671,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(),
|
onPressed: () =>
|
||||||
|
ref.read(authTimelineProvider.notifier).loadMore(),
|
||||||
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,12 +21,7 @@ class Tenant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {'id': id, 'name': name, 'slug': slug, 'description': description};
|
||||||
'id': id,
|
|
||||||
'name': name,
|
|
||||||
'slug': slug,
|
|
||||||
'description': description,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +57,9 @@ class UserProfile {
|
|||||||
department: json['department'] ?? '',
|
department: json['department'] ?? '',
|
||||||
affiliationType: json['affiliationType'] ?? '',
|
affiliationType: json['affiliationType'] ?? '',
|
||||||
companyCode: json['companyCode'] ?? '',
|
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,
|
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,11 +78,7 @@ class UserProfile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
UserProfile copyWith({
|
UserProfile copyWith({String? name, String? phone, String? department}) {
|
||||||
String? name,
|
|
||||||
String? phone,
|
|
||||||
String? department,
|
|
||||||
}) {
|
|
||||||
return UserProfile(
|
return UserProfile(
|
||||||
id: id,
|
id: id,
|
||||||
email: email,
|
email: email,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class ProfileRepository {
|
|||||||
return dotenv.env[key] ?? fallback;
|
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
|
// Helper to get session token
|
||||||
static Future<String?> _getToken() async {
|
static Future<String?> _getToken() async {
|
||||||
@@ -31,9 +32,7 @@ class ProfileRepository {
|
|||||||
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -68,9 +67,7 @@ class ProfileRepository {
|
|||||||
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -107,9 +104,7 @@ class ProfileRepository {
|
|||||||
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -145,9 +140,7 @@ class ProfileRepository {
|
|||||||
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
|
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
@@ -183,9 +176,7 @@ class ProfileRepository {
|
|||||||
|
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
||||||
final client = createHttpClient(withCredentials: useCookie);
|
final client = createHttpClient(withCredentials: useCookie);
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{'Content-Type': 'application/json'};
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
if (!useCookie && token != null) {
|
if (!useCookie && token != null) {
|
||||||
headers['Authorization'] = 'Bearer $token';
|
headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,17 +35,17 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
|||||||
|
|
||||||
// Perform update and then re-fetch profile
|
// Perform update and then re-fetch profile
|
||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(() async {
|
||||||
await ref.read(profileRepositoryProvider).updateMyProfile(
|
await ref
|
||||||
name: name,
|
.read(profileRepositoryProvider)
|
||||||
phone: phone,
|
.updateMyProfile(name: name, phone: phone, department: department);
|
||||||
department: department,
|
|
||||||
);
|
|
||||||
return _fetch();
|
return _fetch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Provider definition
|
// 3. Provider definition
|
||||||
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(() {
|
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
|
||||||
return ProfileNotifier();
|
() {
|
||||||
});
|
return ProfileNotifier();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (_editingField != 'name' && _nameController!.text != profile.name) {
|
if (_editingField != 'name' && _nameController!.text != profile.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;
|
_departmentController!.text = profile.department;
|
||||||
}
|
}
|
||||||
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
|
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
|
||||||
@@ -274,10 +275,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr('msg.userfront.profile.phone.verified', fallback: '인증되었습니다.'),
|
||||||
'msg.userfront.profile.phone.verified',
|
|
||||||
fallback: '인증되었습니다.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -310,24 +308,30 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
|
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
|
||||||
|
|
||||||
if (currentPassword.isEmpty) {
|
if (currentPassword.isEmpty) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.current_required',
|
() => _passwordError = tr(
|
||||||
fallback: '현재 비밀번호를 입력해 주세요.',
|
'msg.userfront.profile.password.current_required',
|
||||||
));
|
fallback: '현재 비밀번호를 입력해 주세요.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword.isEmpty) {
|
if (newPassword.isEmpty) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.new_required',
|
() => _passwordError = tr(
|
||||||
fallback: '새 비밀번호를 입력해 주세요.',
|
'msg.userfront.profile.password.new_required',
|
||||||
));
|
fallback: '새 비밀번호를 입력해 주세요.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword != confirmPassword) {
|
if (newPassword != confirmPassword) {
|
||||||
setState(() => _passwordError = tr(
|
setState(
|
||||||
'msg.userfront.profile.password.mismatch',
|
() => _passwordError = tr(
|
||||||
fallback: '새 비밀번호가 일치하지 않습니다.',
|
'msg.userfront.profile.password.mismatch',
|
||||||
));
|
fallback: '새 비밀번호가 일치하지 않습니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +342,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(profileRepositoryProvider).changePassword(
|
await ref
|
||||||
|
.read(profileRepositoryProvider)
|
||||||
|
.changePassword(
|
||||||
currentPassword: currentPassword,
|
currentPassword: currentPassword,
|
||||||
newPassword: newPassword,
|
newPassword: newPassword,
|
||||||
);
|
);
|
||||||
@@ -434,10 +440,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
tr('msg.userfront.profile.name_required', fallback: '이름을 입력해주세요.'),
|
||||||
'msg.userfront.profile.name_required',
|
|
||||||
fallback: '이름을 입력해주세요.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -500,7 +503,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
_isSavingField = true;
|
_isSavingField = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(profileProvider.notifier).updateProfile(
|
await ref
|
||||||
|
.read(profileProvider.notifier)
|
||||||
|
.updateProfile(
|
||||||
name: nextName,
|
name: nextName,
|
||||||
phone: nextPhone,
|
phone: nextPhone,
|
||||||
department: nextDepartment,
|
department: nextDepartment,
|
||||||
@@ -551,32 +556,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.home_outlined),
|
leading: const Icon(Icons.home_outlined),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
|
||||||
tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
|
|
||||||
),
|
|
||||||
onTap: () => context.go('/'),
|
onTap: () => context.go('/'),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.person_outline),
|
leading: const Icon(Icons.person_outline),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
|
||||||
tr('ui.userfront.nav.profile', fallback: '내 정보'),
|
|
||||||
),
|
|
||||||
selected: true,
|
selected: true,
|
||||||
onTap: () => context.go('/profile'),
|
onTap: () => context.go('/profile'),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.qr_code_scanner),
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
|
||||||
tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
|
|
||||||
),
|
|
||||||
onTap: () => context.go('/scan'),
|
onTap: () => context.go('/scan'),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.logout),
|
leading: const Icon(Icons.logout),
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
|
||||||
tr('ui.userfront.nav.logout', fallback: '로그아웃'),
|
|
||||||
),
|
|
||||||
onTap: _logout,
|
onTap: _logout,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -589,13 +586,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
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),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||||
subtitle,
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -615,7 +613,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const CircleAvatar(
|
const CircleAvatar(radius: 32, child: Icon(Icons.person, size: 32)),
|
||||||
radius: 32,
|
|
||||||
child: Icon(Icons.person, size: 32),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -672,7 +671,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
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),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -682,7 +684,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
Icons.badge_outlined,
|
Icons.badge_outlined,
|
||||||
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
|
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) {
|
if (!isEditing) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.profile.phone.title', fallback: '전화번호')),
|
||||||
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
|
|
||||||
),
|
|
||||||
subtitle: Text(displayValue),
|
subtitle: Text(displayValue),
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
|
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
|
||||||
@@ -918,7 +921,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility),
|
icon: Icon(
|
||||||
|
_showCurrentPassword
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showCurrentPassword = !_showCurrentPassword;
|
_showCurrentPassword = !_showCurrentPassword;
|
||||||
}),
|
}),
|
||||||
@@ -936,7 +943,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility),
|
icon: Icon(
|
||||||
|
_showNewPassword ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showNewPassword = !_showNewPassword;
|
_showNewPassword = !_showNewPassword;
|
||||||
}),
|
}),
|
||||||
@@ -954,7 +963,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility),
|
icon: Icon(
|
||||||
|
_showConfirmPassword
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
|
),
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
_showConfirmPassword = !_showConfirmPassword;
|
_showConfirmPassword = !_showConfirmPassword;
|
||||||
}),
|
}),
|
||||||
@@ -963,10 +976,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
if (_passwordError != null) ...[
|
if (_passwordError != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(_passwordError!, style: const TextStyle(color: Colors.red)),
|
||||||
_passwordError!,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
if (_passwordSuccess != null) ...[
|
if (_passwordSuccess != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -1037,7 +1047,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildEditableTile(
|
_buildEditableTile(
|
||||||
field: 'name',
|
field: 'name',
|
||||||
label: tr('ui.userfront.profile.field.name', fallback: '이름'),
|
label: tr(
|
||||||
|
'ui.userfront.profile.field.name',
|
||||||
|
fallback: '이름',
|
||||||
|
),
|
||||||
value: profile.name,
|
value: profile.name,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
@@ -1045,7 +1058,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.email', fallback: '이메일'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.email',
|
||||||
|
fallback: '이메일',
|
||||||
|
),
|
||||||
profile.email,
|
profile.email,
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
@@ -1055,7 +1071,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_buildSectionTitle(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.profile.section.organization', fallback: '조직 정보'),
|
tr(
|
||||||
|
'ui.userfront.profile.section.organization',
|
||||||
|
fallback: '조직 정보',
|
||||||
|
),
|
||||||
tr(
|
tr(
|
||||||
'msg.userfront.profile.section.organization',
|
'msg.userfront.profile.section.organization',
|
||||||
fallback: '소속 및 구분 정보입니다.',
|
fallback: '소속 및 구분 정보입니다.',
|
||||||
@@ -1067,7 +1086,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildEditableTile(
|
_buildEditableTile(
|
||||||
field: 'department',
|
field: 'department',
|
||||||
label: tr('ui.userfront.profile.field.department', fallback: '소속'),
|
label: tr(
|
||||||
|
'ui.userfront.profile.field.department',
|
||||||
|
fallback: '소속',
|
||||||
|
),
|
||||||
value: profile.department,
|
value: profile.department,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
isUpdating: isUpdating,
|
isUpdating: isUpdating,
|
||||||
@@ -1075,7 +1097,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.affiliation', fallback: '구분'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.affiliation',
|
||||||
|
fallback: '구분',
|
||||||
|
),
|
||||||
profile.affiliationType,
|
profile.affiliationType,
|
||||||
),
|
),
|
||||||
if (profile.tenant != null) ...[
|
if (profile.tenant != null) ...[
|
||||||
@@ -1091,7 +1116,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
if (profile.companyCode.isNotEmpty) ...[
|
if (profile.companyCode.isNotEmpty) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
_buildReadOnlyTile(
|
_buildReadOnlyTile(
|
||||||
tr('ui.userfront.profile.field.company_code', fallback: '회사코드'),
|
tr(
|
||||||
|
'ui.userfront.profile.field.company_code',
|
||||||
|
fallback: '회사코드',
|
||||||
|
),
|
||||||
profile.companyCode,
|
profile.companyCode,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1148,7 +1176,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
|
onPressed: () =>
|
||||||
|
ref.read(profileProvider.notifier).loadProfile(),
|
||||||
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
child: Text(tr('ui.common.retry', fallback: '재시도')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1193,11 +1222,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide)
|
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
|
||||||
SizedBox(
|
|
||||||
width: 240,
|
|
||||||
child: _buildSideMenu(context),
|
|
||||||
),
|
|
||||||
Expanded(child: _buildContent(profile, isUpdating)),
|
Expanded(child: _buildContent(profile, isUpdating)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ class ProfileInfoRow extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
|
||||||
const ProfileInfoRow({
|
const ProfileInfoRow({super.key, required this.label, required this.value});
|
||||||
super.key,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user