1
0
forked from baron/baron-sso

Merge pull request 'feature/tenant-group' (#249) from feature/tenant-group into dev

Reviewed-on: baron/baron-sso#249
This commit is contained in:
2026-02-12 10:49:53 +09:00
105 changed files with 37200 additions and 1351 deletions

View File

@@ -88,6 +88,8 @@ HYDRA_VERSION=v25.4.0-distroless
# Ory Keto Configuration
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_WRITE_PORT=4467 # Internal only

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,18 @@ import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import 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 TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
@@ -17,6 +26,14 @@ import UserListPage from "../features/users/UserListPage";
export const router = createBrowserRouter(
[
{
path: "/login",
element: <LoginPage />,
},
{
path: "/auth/callback",
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AppLayout />,
@@ -30,11 +47,23 @@ export const router = createBrowserRouter(
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ 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",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "admins", element: <TenantAdminsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},

View File

@@ -4,14 +4,17 @@ import {
Key,
KeyRound,
LayoutDashboard,
LayoutGrid,
LogOut,
Moon,
NotebookTabs,
Rocket,
ShieldHalf,
Sun,
Users,
} from "lucide-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 RoleSwitcher from "./RoleSwitcher";
@@ -22,19 +25,40 @@ const navItems = [
to: "/dashboard",
icon: ShieldHalf,
},
{
label: "ui.admin.nav.tenant_groups",
to: "/tenant-groups",
icon: LayoutGrid,
},
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const handleLogout = () => {
if (
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
) {
window.localStorage.removeItem("admin_session");
navigate("/login");
}
};
useEffect(() => {
const session = window.localStorage.getItem("admin_session");
if (!session) {
navigate("/login");
}
}, [navigate]);
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
@@ -72,36 +96,50 @@ function AppLayout() {
{t("msg.admin.scope_admin", "Scoped to /admin")}
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.idp_env_prod", "IDP env: prod")}
</span>
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
</span>
<nav className="px-2 pb-4 md:px-3 md:pb-8 h-[calc(100vh-200px)] flex flex-col justify-between">
<div className="space-y-1">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.idp_env_prod", "IDP env: prod")}
</span>
<span className="rounded-full border border-border px-3 py-1">
{t("msg.admin.tenant_headers", "Tenant-aware headers")}
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
))}
</div>
</div>
<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 className="px-3 pt-4 border-t border-border/50">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
{" "}
<p>
{t(
"msg.admin.notice.scope",

View File

@@ -26,7 +26,8 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {

View File

@@ -34,7 +34,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {

View 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;

View 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;

View File

@@ -17,6 +17,7 @@ import {
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
const summaryCards = [
{
@@ -216,6 +217,8 @@ function GlobalOverviewPage() {
</CardContent>
</Card>
</div>
<PermissionChecker />
</div>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -16,6 +16,7 @@ function TenantDetailPage() {
});
const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins");
return (
<div className="space-y-8">
@@ -44,7 +45,9 @@ function TenantDetailPage() {
<Link
to={`/tenants/${tenantId}`}
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"
: "text-gray-500 hover:text-gray-700"
}`}
@@ -61,6 +64,16 @@ function TenantDetailPage() {
>
Federation
</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
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${

View File

@@ -18,6 +18,7 @@ import {
approveTenant,
deleteTenant,
fetchTenant,
fetchTenantGroups,
updateTenant,
} from "../../../lib/adminApi";
@@ -35,11 +36,17 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId),
});
const groupsQuery = useQuery({
queryKey: ["tenant-groups", { limit: 100 }],
queryFn: () => fetchTenantGroups(100, 0),
});
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [tenantGroupId, setTenantGroupId] = useState("");
useEffect(() => {
if (tenantQuery.data) {
@@ -48,6 +55,7 @@ export function TenantProfilePage() {
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
}
}, [tenantQuery.data]);
@@ -58,6 +66,7 @@ export function TenantProfilePage() {
slug,
description: description || undefined,
status,
tenantGroupId: tenantGroupId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
@@ -136,6 +145,25 @@ export function TenantProfilePage() {
onChange={(e) => setDescription(e.target.value)}
/>
</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">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)

View File

@@ -54,7 +54,8 @@
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
background-image:
radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%

View File

@@ -27,6 +27,7 @@ export type TenantSummary = {
status: string;
domains?: string[];
config?: Record<string, unknown>;
tenantGroupId?: string;
createdAt: string;
updatedAt: string;
};
@@ -38,6 +39,7 @@ export type TenantCreateRequest = {
status?: string;
domains?: string[];
config?: Record<string, unknown>;
tenantGroupId?: string;
};
export type TenantListResponse = {
@@ -54,6 +56,7 @@ export type TenantUpdateRequest = {
status?: string;
domains?: string[];
config?: Record<string, unknown>;
tenantGroupId?: string;
};
export type ApiKeySummary = {
@@ -92,6 +95,26 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
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) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
@@ -139,7 +162,7 @@ export async function approveTenant(tenantId: string) {
return data;
}
// Group Management
// User Group Management (Within a Tenant)
export type GroupMember = {
id: string;
name: string;
@@ -191,6 +214,123 @@ export async function removeGroupMember(groupId: string, userId: string) {
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)
export type ApiKeyCreateRequest = {
name: string;
@@ -371,3 +511,33 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
}
export type RPOwner = {
subject: string;
name?: string;
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}`,
);
}

View File

@@ -29,7 +29,10 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
if (error.response?.status === 401) {
window.localStorage.removeItem("admin_session");
window.location.href = "/login";
}
return Promise.reject(error);
},
);

View File

@@ -108,10 +108,10 @@ function detectLocale(): Locale {
}
// eslint-disable-next-line import/no-unresolved
import enRaw from "../../../locales/en.toml?raw";
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// 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> = {
ko: parseToml(koRaw),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_"],
server: {
host: "0.0.0.0",
proxy: {

View File

@@ -245,7 +245,9 @@ func main() {
// 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db)
tenantGroupRepo := repository.NewTenantGroupRepository(db)
tenantService := service.NewTenantService(tenantRepo)
tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService)
tenantService.SetKetoService(ketoService) // Keto 주입
userRepo := repository.NewUserRepository(db)
// relyingPartyRepo removed as SSOT is now Hydra+Keto
@@ -254,14 +256,16 @@ func main() {
secretRepo := repository.NewClientSecretRepository(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()
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)
apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -556,6 +560,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission)
// Tenant Management (Super Admin Only)
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.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
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)
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)
admin.Post("/tenants/:tenantId/relying-parties",
@@ -607,14 +630,24 @@ func main() {
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
dev := api.Group("/dev")
dev := api.Group("/dev", requireAdmin)
dev.Get("/clients", devHandler.ListClients)
dev.Post("/clients", devHandler.CreateClient)
dev.Get("/clients/:id", devHandler.GetClient)
dev.Put("/clients/:id", devHandler.UpdateClient)
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
dev.Delete("/clients/:id", devHandler.DeleteClient)
dev.Get("/clients/:id",
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
devHandler.GetClient)
dev.Put("/clients/:id",
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.Delete("/consents", devHandler.RevokeConsents)

View File

@@ -31,6 +31,7 @@ func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
// Add all domain models here
return db.AutoMigrate(
&domain.TenantGroup{},
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},

View File

@@ -25,6 +25,18 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
if t.ParentID != nil {
_ = 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

View File

@@ -68,18 +68,19 @@ type SignupRequest struct {
// User Profile Models
type UserProfileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
}
type UpdateUserRequest struct {

View File

@@ -17,23 +17,47 @@ const (
// Tenant represents a tenant model stored in PostgreSQL.
type Tenant struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"`
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (t *Tenant) IsActive() bool {
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.
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" {

View 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
}

View File

@@ -1,22 +1,51 @@
package handler
import (
"baron-sso-backend/internal/service"
"runtime"
"time"
"github.com/gofiber/fiber/v2"
)
type AdminHandler struct{}
type AdminHandler struct {
Keto service.KetoService
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
func NewAdminHandler(keto service.KetoService) *AdminHandler {
return &AdminHandler{Keto: keto}
}
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
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
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats

View File

@@ -125,10 +125,11 @@ func GenerateSecureAlnumToken(length int) string {
func GenerateUserCode() string {
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))],
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"})
}
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
userCode := GenerateUserCode()
token := GenerateSecureToken(3)
pendingRef := GenerateSecureToken(3)
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
sessionData, _ := json.Marshal(map[string]string{
"status": statusPending,
@@ -1018,12 +1026,13 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
}
} else {
// Send SMS
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode)
phone := sanitizePhoneForSms(loginID)
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 간편 코드: %s", link, userCode)
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 {
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
if err := h.SmsService.SendSms(loginID, content); err != nil {
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "to", phone)
if err := h.SmsService.SendSms(phone, content); err != nil {
slog.Error("[Enchanted] SMS Failed", "error", err)
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 로그인 흐름 처리 끝 ---
resp := fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT,
"status": "ok",
"provider": h.IdpProvider.Name(),
"sessionToken": authInfo.SessionToken.JWT,
"status": "ok",
"provider": h.IdpProvider.Name(),
}
if authInfo.RefreshToken != nil {
resp["refreshJwt"] = authInfo.RefreshToken.JWT
resp["refreshToken"] = authInfo.RefreshToken.JWT
}
if authInfo.Subject != "" {
resp["subject"] = authInfo.Subject
@@ -2066,6 +2075,16 @@ type kratosCourierRequest struct {
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 발송으로 변환합니다.
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
var req kratosCourierRequest
@@ -2444,16 +2463,6 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
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 ---
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)
if h.RedisService != nil && cacheKey != "" {
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) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", nil, err
@@ -4807,10 +4820,7 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
}
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", err
@@ -4833,6 +4843,7 @@ func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
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")
}
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
if kratosAdminURL == "" {
kratosAdminURL = "http://kratos:4434"
}
kratosAdminURL := strings.TrimRight(utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"), "/")
payload := map[string]interface{}{
"identity_id": identityID,

View File

@@ -288,8 +288,8 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
}
var got map[string]string
json.NewDecoder(resp.Body).Decode(&got)
if got["sessionJwt"] != "valid-jwt" {
t.Errorf("expected jwt valid-jwt, got %s", got["sessionJwt"])
if got["sessionToken"] != "valid-jwt" {
t.Errorf("expected jwt valid-jwt, got %s", got["sessionToken"])
}
// No redirectTo
if _, ok := got["redirectTo"]; ok {

View File

@@ -22,15 +22,17 @@ type DevHandler struct {
SecretRepo domain.ClientSecretRepository
KratosAdmin *service.KratosAdminService
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{
Hydra: service.NewHydraAdminService(),
Redis: redis,
SecretRepo: secretRepo,
KratosAdmin: service.NewKratosAdminService(),
ConsentRepo: consentRepo,
RPService: rpService,
}
}
@@ -95,38 +97,58 @@ type clientUpsertRequest struct {
}
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found"})
}
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 errors.Is(err, service.ErrHydraNotFound) {
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})
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))
items := make([]clientSummary, 0, len(rps))
for _, rp := range rps {
// 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{
Items: items,
Limit: limit,
Offset: offset,
Limit: len(items),
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()})
}
// Set for audit logging
if tid, ok := client.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
summary := h.mapClientSummary(*client)
return c.JSON(clientDetailResponse{
Client: summary,
@@ -197,11 +224,49 @@ func (h *DevHandler) UpdateClientStatus(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
if err := c.BodyParser(&req); err != nil {
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, ""))
if clientID == "" {
clientID = uuid.NewString()
@@ -257,11 +322,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
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 {
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
if created.ClientSecret != "" {
// 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()})
}
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
clientType := ""
if req.Type != nil {
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"})
}
// 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 errors.Is(err, service.ErrHydraNotFound) {
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 {
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"))
if clientID == "" {
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"))
limit := c.QueryInt("limit", 50)
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 {
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"))
if subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
}
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 _, err := uuid.Parse(subject); err != nil {
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()})
}
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
// 3. Update Hydra
current.ClientSecret = newSecret
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)

View File

@@ -1,8 +1,10 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -10,8 +12,75 @@ import (
"github.com/gofiber/fiber/v2"
"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) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
@@ -30,7 +99,11 @@ func TestListClients_Success(t *testing.T) {
},
}
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)
resp, _ := app.Test(req, -1)
@@ -66,7 +139,11 @@ func TestGetClient_Success(t *testing.T) {
},
}
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)
resp, _ := app.Test(req, -1)
@@ -92,7 +169,11 @@ func TestGetClient_NotFound(t *testing.T) {
},
}
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)
resp, _ := app.Test(req, -1)
@@ -109,30 +190,49 @@ func TestCreateClient_Success(t *testing.T) {
"client_secret": "secret-123",
}), 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)}
redisRepo := &mockRedisRepo{data: make(map[string]string)}
mockRP := new(MockRPService)
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
PublicURL: "http://hydra-public.test",
HTTPClient: &http.Client{Transport: transport},
},
SecretRepo: secretRepo,
Redis: redisRepo,
RPService: mockRP,
}
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{}{
"client_name": "New App",
"type": "confidential",
"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.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tenant-ID", "t1")
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode)

View File

@@ -10,10 +10,11 @@ import (
type RelyingPartyHandler struct {
Service service.RelyingPartyService
UserSvc *service.KratosAdminService
}
func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s}
func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s, UserSvc: userSvc}
}
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)
}
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"})
}

View 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),
}
}

View File

@@ -14,22 +14,25 @@ import (
type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
Keto service.KetoService
UserSvc *service.KratosAdminService
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc}
}
type tenantSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Status string `json:"status"`
TenantGroupID *string `json:"tenantGroupId,omitempty"`
Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type tenantListResponse struct {
@@ -100,7 +103,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
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()})
}
@@ -123,7 +126,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
}
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) {
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 {
Name *string `json:"name"`
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
Name *string `json:"name"`
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
TenantGroupID *string `json:"tenantGroupId"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
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
}
// 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 {
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)
}
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 {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {
@@ -308,15 +387,16 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
}
return tenantSummary{
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
Description: t.Description,
Status: t.Status,
Domains: domains,
Config: t.Config,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
Description: t.Description,
Status: t.Status,
TenantGroupID: t.TenantGroupID,
Domains: domains,
Config: t.GetMergedConfig(),
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -70,6 +70,26 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) {
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) {
app := fiber.New()
mockSvc := new(MockTenantService)

View 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)
}

View File

@@ -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"})
}
// Set tenant_id for audit logging if namespace is Tenant
if namespace == "Tenant" {
c.Locals("tenant_id", objectID)
}
// Check with Keto
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
if err != nil || !allowed {

View File

@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// Fixed MockKetoService to match service.KetoService exactly if possible.
// Wait, middleware/rbac.go imports baron-sso-backend/internal/service.
// So I should use service.RelationTuple.

View 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
}

View File

@@ -14,6 +14,7 @@ type TenantRepository interface {
FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
FindByName(ctx context.Context, name 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
}
@@ -41,6 +42,17 @@ func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Ten
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) {
var tenant domain.Tenant
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {

View File

@@ -2,6 +2,7 @@ package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/json"
@@ -27,8 +28,8 @@ type HydraAdminService struct {
func NewHydraAdminService() *HydraAdminService {
return &HydraAdminService{
AdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
PublicURL: getenv("HYDRA_PUBLIC_URL", "http://hydra:4444"),
AdminURL: utils.GetEnv("HYDRA_ADMIN_URL", "http://hydra:4445"),
PublicURL: utils.GetEnv("HYDRA_PUBLIC_URL", "http://hydra:4444"),
}
}

View File

@@ -1,6 +1,7 @@
package service
import (
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/json"
@@ -9,7 +10,6 @@ import (
"log/slog"
"net/http"
"net/url"
"os"
"time"
)
@@ -18,6 +18,7 @@ type KetoService interface {
CreateRelation(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)
ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error)
}
type ketoService struct {
@@ -27,14 +28,8 @@ type ketoService struct {
}
func NewKetoService() KetoService {
readURL := os.Getenv("KETO_READ_URL")
if readURL == "" {
readURL = "http://keto:4466"
}
writeURL := os.Getenv("KETO_WRITE_URL")
if writeURL == "" {
writeURL = "http://keto:4467"
}
readURL := utils.GetEnv("KETO_READ_URL", "http://keto:4466")
writeURL := utils.GetEnv("KETO_WRITE_URL", "http://keto:4467")
return &ketoService{
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)
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
}

View File

@@ -1,6 +1,7 @@
package service
import (
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/json"
@@ -28,7 +29,7 @@ type KratosAdminService struct {
func NewKratosAdminService() *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 {
if v := os.Getenv(key); v != "" {
return v
v := os.Getenv(key)
if v == "" {
return fallback
}
return fallback
return strings.Trim(v, "\"")
}

View File

@@ -2,6 +2,7 @@ package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/json"
@@ -27,9 +28,9 @@ type OryProvider struct {
func NewOryProvider() *OryProvider {
return &OryProvider{
KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"),
KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"),
HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
KratosAdminURL: utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"),
KratosPublicURL: utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"),
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 {
if v := os.Getenv(key); v != "" {
return v
v := os.Getenv(key)
if v == "" {
return fallback
}
return fallback
// Strip surrounding double quotes if present
return strings.Trim(v, "\"")
}
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환

View File

@@ -15,6 +15,10 @@ type RelyingPartyService interface {
ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error)
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, 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 {
@@ -158,6 +162,31 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
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 {
if client == nil {
return nil

View File

@@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object,
return args.Get(0).([]RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// --- Test Helpers ---
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)

View 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
}

View 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)
}

View File

@@ -18,8 +18,12 @@ type TenantService interface {
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug 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
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 {
@@ -39,6 +43,60 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
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) {
// Validate Slug
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) {
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
}

View 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
}

View File

@@ -26,7 +26,8 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {

View File

@@ -34,7 +34,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {

View File

@@ -54,7 +54,8 @@
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
background-image:
radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%

View File

@@ -108,10 +108,10 @@ function detectLocale(): Locale {
}
// eslint-disable-next-line import/no-unresolved
import enRaw from "../../../locales/en.toml?raw";
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// 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> = {
ko: parseToml(koRaw),

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@ services:
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
- 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
- CLICKHOUSE_HOST=clickhouse
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
@@ -54,6 +56,7 @@ services:
environment:
- APP_ENV=${APP_ENV:-development}
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
ports:
- "${ADMIN_PORT:-5173}:5173"
volumes:
@@ -72,6 +75,7 @@ services:
environment:
- APP_ENV=${APP_ENV:-development}
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
ports:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:

View File

@@ -2,6 +2,12 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
class User implements Namespace {}
class TenantGroup implements Namespace {
related: {
admins: User[]
}
}
class UserGroup implements Namespace {
related: {
members: User[]
@@ -19,17 +25,20 @@ class Tenant implements Namespace {
admins: User[]
members: User[]
parent: Tenant[]
parent_group: TenantGroup[]
}
permits = {
view: (ctx: Context): boolean =>
this.related.members.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 =>
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 =>
this.permits.manage(ctx)

View File

@@ -20,6 +20,8 @@ selfservice:
- https://sso.hmac.kr/
- https://app.hmac.kr
- https://app.hmac.kr/
- https://ssologin.hmac.kr
- https://ssologin.hmac.kr/
methods:
password:

View File

@@ -24,6 +24,9 @@ server {
client_header_buffer_size 16k;
large_client_header_buffers 4 64k;
include /etc/nginx/mime.types;
types {
application/javascript mjs;
}
resolver 127.0.0.11 valid=10s ipv6=off;
set $backend_upstream http://baron_backend:3000;

View File

@@ -43,6 +43,7 @@ missing = "Missing"
[msg]
[msg.admin]
logout_confirm = "Are you sure you want to log out?"
idp_env_prod = "IDP env: prod"
scope_admin = "Scoped to /admin"
session_ttl = "Session TTL: 15m admin"
@@ -841,9 +842,6 @@ role = "ROLE"
status = "STATUS"
tenant_dept = "TENANT / DEPT"
[ui.btn]
cancel = "Cancel"
save = "Save"
[ui.common]
add = "Add"
@@ -1091,8 +1089,6 @@ title = "Stack readiness"
plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.nav]
dashboard = "Dashboard"
[ui.userfront]
app_title = "App Title"
@@ -1306,3 +1302,15 @@ verify = "Verify"
[ui.userfront.signup.success]
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"

View File

@@ -43,6 +43,7 @@ missing = "활성 세션이 없습니다."
[msg]
[msg.admin]
logout_confirm = "로그아웃 하시겠습니까?"
idp_env_prod = "IDP env: prod"
scope_admin = "Scoped to /admin"
session_ttl = "Session TTL: 15m admin"
@@ -841,9 +842,6 @@ role = "ROLE"
status = "STATUS"
tenant_dept = "TENANT / DEPT"
[ui.btn]
cancel = "취소"
save = "저장"
[ui.common]
add = "추가"
@@ -1091,8 +1089,6 @@ title = "Stack readiness"
plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.nav]
dashboard = "대시보드"
[ui.userfront]
app_title = "Baron 로그인"
@@ -1306,3 +1302,15 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"
[ui.admin.nav]
api_keys = "API 키"
audit_logs = "감사 로그"
auth_guard = "인증 가드"
logout = "로그아웃"
overview = "개요"
relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드"
tenant_groups = "테넌트 그룹"
tenants = "테넌트"
users = "사용자"

View File

@@ -44,6 +44,7 @@ missing = ""
[msg.admin]
idp_env_prod = ""
logout_confirm = ""
scope_admin = ""
session_ttl = ""
tenant_headers = ""
@@ -659,6 +660,18 @@ name = ""
[ui.admin.header]
plane = ""
[ui.admin.nav]
api_keys = ""
audit_logs = ""
auth_guard = ""
logout = ""
overview = ""
relying_parties = ""
tenant_dashboard = ""
tenant_groups = ""
tenants = ""
users = ""
[ui.admin.overview]
kicker = ""
title = ""
@@ -841,9 +854,6 @@ role = ""
status = ""
tenant_dept = ""
[ui.btn]
cancel = ""
save = ""
[ui.common]
add = ""
@@ -1091,8 +1101,6 @@ title = ""
plane = ""
subtitle = ""
[ui.nav]
dashboard = ""
[ui.userfront]
app_title = ""

View File

@@ -7,7 +7,7 @@ COPY . .
# Get dependencies and build for web
RUN flutter pub get
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
FROM nginx:alpine

View File

@@ -11,7 +11,8 @@ class AuditService {
return dotenv.env[key] ?? fallback;
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static String get _baseUrl =>
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static Future<void> logEvent({
required String userId,
@@ -20,7 +21,7 @@ class AuditService {
String? details,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/audit');
try {
final response = await http.post(
url,

View File

@@ -19,10 +19,12 @@ class AuthProxyService {
// 배포 환경에서 $ 기호나 공백이 섞여 들어오는 경우를 방지하기 위해 정제합니다.
return rawUrl.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
static bool get _isProd {
final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
return env == 'prod' || env == 'production';
}
static bool get isProdEnv => _isProd;
static bool _shouldSendDrySend(bool? drySend) {
if (_isProd) {
@@ -76,13 +78,14 @@ class AuthProxyService {
}
}
static Future<int> getSessionStatus({String? token, bool useCookie = false}) async {
static Future<int> getSessionStatus({
String? token,
bool useCookie = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
@@ -101,11 +104,8 @@ class AuthProxyService {
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
final body = <String, dynamic>{
'loginId': loginId,
'uri': userfrontUrl,
};
final body = <String, dynamic>{'loginId': loginId, 'uri': userfrontUrl};
if (_shouldSendDrySend(drySend)) {
body['drySend'] = true;
}
@@ -115,7 +115,7 @@ class AuthProxyService {
if (codeOnly == true) {
body['codeOnly'] = true;
}
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -133,15 +133,15 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async {
static Future<Map<String, dynamic>> pollEnchantedLink(
String pendingRef,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'pendingRef': pendingRef,
}),
body: jsonEncode({'pendingRef': pendingRef}),
);
if (response.statusCode == 200) {
@@ -157,16 +157,16 @@ class AuthProxyService {
);
}
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async {
static Future<Map<String, dynamic>> verifyMagicLink(
String token, {
bool verifyOnly = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'token': token,
'verifyOnly': verifyOnly,
}),
body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}),
);
if (response.statusCode == 200) {
@@ -223,10 +223,7 @@ class AuthProxyService {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'shortCode': shortCode,
'verifyOnly': verifyOnly,
}),
body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}),
);
if (response.statusCode == 200) {
@@ -240,13 +237,18 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password, {String? loginChallenge}) async {
static Future<Map<String, dynamic>> loginWithPassword(
String loginId,
String password, {
String? loginChallenge,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
final payload = {
'loginId': loginId,
'password': password,
if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge,
if (loginChallenge != null && loginChallenge.isNotEmpty)
'login_challenge': loginChallenge,
};
final response = await http.post(
@@ -272,8 +274,13 @@ class AuthProxyService {
);
}
}
static Future<Map<String, dynamic>> getConsentInfo(String consentChallenge) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent').replace(queryParameters: {'consent_challenge': consentChallenge});
static Future<Map<String, dynamic>> getConsentInfo(
String consentChallenge,
) async {
final url = Uri.parse(
'$_baseUrl/api/v1/auth/consent',
).replace(queryParameters: {'consent_challenge': consentChallenge});
final response = await http.get(
url,
headers: {'Content-Type': 'application/json'},
@@ -293,11 +300,12 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> acceptConsent(String consentChallenge, {List<String>? grantScope}) async {
static Future<Map<String, dynamic>> acceptConsent(
String consentChallenge, {
List<String>? grantScope,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
final body = <String, dynamic>{
'consent_challenge': consentChallenge,
};
final body = <String, dynamic>{'consent_challenge': consentChallenge};
if (grantScope != null) {
body['grant_scope'] = grantScope;
}
@@ -322,11 +330,11 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> rejectConsent(String consentChallenge) async {
static Future<Map<String, dynamic>> rejectConsent(
String consentChallenge,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
final body = <String, dynamic>{
'consent_challenge': consentChallenge,
};
final body = <String, dynamic>{'consent_challenge': consentChallenge};
final response = await http.post(
url,
@@ -353,9 +361,7 @@ class AuthProxyService {
String? token,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
@@ -364,9 +370,7 @@ class AuthProxyService {
final response = await client.post(
url,
headers: headers,
body: jsonEncode({
'login_challenge': loginChallenge,
}),
body: jsonEncode({'login_challenge': loginChallenge}),
);
if (response.statusCode == 200) {
@@ -386,8 +390,10 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async {
static Future<Map<String, dynamic>> initiatePasswordReset(
String loginId, {
bool? drySend,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post(
url,
@@ -424,7 +430,9 @@ class AuthProxyService {
if (token != null && token.isNotEmpty) {
query['token'] = token;
}
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query);
final url = Uri.parse(
'$_baseUrl/api/v1/auth/password/reset/complete',
).replace(queryParameters: query);
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -447,13 +455,11 @@ class AuthProxyService {
static Future<void> sendSms(String phoneNumber) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
}),
body: jsonEncode({'phoneNumber': phoneNumber}),
);
if (response.statusCode != 200) {
@@ -465,16 +471,16 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> verifySmsCode(String phoneNumber, String code) async {
static Future<Map<String, dynamic>> verifySmsCode(
String phoneNumber,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phoneNumber': phoneNumber,
'code': code,
}),
body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}),
);
if (response.statusCode == 200) {
@@ -532,10 +538,10 @@ class AuthProxyService {
String? token,
bool withCredentials = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
final payload = <String, dynamic>{
'pendingRef': pendingRef,
};
final url = Uri.parse(
'$_baseUrl/api/v1/auth/qr/approve',
); // Mapping to ScanQRLogin on backend
final payload = <String, dynamic>{'pendingRef': pendingRef};
if (token != null && token.isNotEmpty) {
payload['token'] = token;
}
@@ -593,7 +599,7 @@ class AuthProxyService {
String? displayName,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/users');
final response = await http.post(
url,
headers: {
@@ -617,7 +623,10 @@ class AuthProxyService {
}
}
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async {
static Future<List<dynamic>> listUsers(
String adminPassword, {
String? query,
}) async {
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
if (query != null && query.isNotEmpty) {
uri = uri.replace(queryParameters: {'text': query});
@@ -646,7 +655,7 @@ class AuthProxyService {
static Future<void> deleteUser(String adminPassword, String loginId) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final response = await http.delete(
url,
headers: {
@@ -664,10 +673,14 @@ class AuthProxyService {
}
}
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async {
static Future<void> updateUserStatus(
String adminPassword,
String loginId,
String status,
) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
final response = await http.patch(
url,
headers: {
@@ -695,7 +708,7 @@ class AuthProxyService {
}) async {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final body = <String, dynamic>{};
if (email != null) body['email'] = email;
if (phone != null) body['phone'] = phone;
@@ -725,18 +738,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.get(
url,
headers: headers,
);
final response = await client.get(url, headers: headers);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
@@ -758,18 +766,13 @@ class AuthProxyService {
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.delete(
url,
headers: headers,
);
final response = await client.delete(url, headers: headers);
if (response.statusCode != 200) {
final errorBody = jsonDecode(response.body);
@@ -786,7 +789,11 @@ class AuthProxyService {
}
}
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
static Future<void> sendLog(
String level,
String message, {
Map<String, dynamic>? data,
}) async {
if (!_canSendClientLog()) {
return;
}
@@ -808,11 +815,15 @@ class AuthProxyService {
}
}
static Future<void> logError(String message, {dynamic error, StackTrace? stackTrace}) async {
static Future<void> logError(
String message, {
dynamic error,
StackTrace? stackTrace,
}) async {
final data = <String, dynamic>{};
if (error != null) data['error'] = error.toString();
if (stackTrace != null) data['stack'] = stackTrace.toString();
await sendLog('ERROR', message, data: data);
}
@@ -861,7 +872,7 @@ class AuthProxyService {
static Future<void> sendSignupCode(String target, String type) async {
final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
@@ -877,17 +888,17 @@ class AuthProxyService {
}
}
static Future<bool> verifySignupCode(String target, String type, String code) async {
static Future<bool> verifySignupCode(
String target,
String type,
String code,
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'target': target,
'type': type,
'code': code,
}),
body: jsonEncode({'target': target, 'type': type, 'code': code}),
);
if (response.statusCode == 200) {
@@ -908,7 +919,7 @@ class AuthProxyService {
required bool termsAccepted,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},

View File

@@ -1,6 +1,5 @@
import 'package:http/http.dart' as http;
import 'http_client_stub.dart'
if (dart.library.html) 'http_client_web.dart';
import 'http_client_stub.dart' if (dart.library.html) 'http_client_web.dart';
http.Client createHttpClient({bool withCredentials = false}) {
return httpClientFactory.create(withCredentials: withCredentials);

View File

@@ -25,8 +25,10 @@ class LoggerService {
);
// 2. Configure Standard Logger (logging package)
std_log.Logger.root.level = kReleaseMode ? std_log.Level.WARNING : std_log.Level.ALL;
std_log.Logger.root.level = kReleaseMode
? std_log.Level.WARNING
: std_log.Level.ALL;
std_log.Logger.root.onRecord.listen((record) {
if (kReleaseMode) {
// [Production] Log as JSON
@@ -41,13 +43,17 @@ class LoggerService {
/// Initialize the logger. Call this in main.dart
static void init() {
// Accessing the instance triggers the constructor
LoggerService();
LoggerService();
std_log.Logger('BaronSSO').info('Logger initialized');
}
void _logPretty(std_log.LogRecord record) {
if (record.level >= std_log.Level.SEVERE) {
_prettyLogger.e(record.message, error: record.error, stackTrace: record.stackTrace);
_prettyLogger.e(
record.message,
error: record.error,
stackTrace: record.stackTrace,
);
} else if (record.level >= std_log.Level.WARNING) {
_prettyLogger.w(record.message);
} else if (record.level >= std_log.Level.INFO) {
@@ -66,7 +72,7 @@ class LoggerService {
if (record.error != null) 'error': record.error.toString(),
if (record.stackTrace != null) 'stack': record.stackTrace.toString(),
};
// 1. Print to Browser Console (F12)
debugPrint(jsonEncode(logData));

View File

@@ -3,10 +3,65 @@
import 'dart:async';
import 'dart:html' as html;
import 'package:flutter/foundation.dart';
import 'auth_token_store.dart';
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) {
try {
html.window.opener!.postMessage(message, '*');
@@ -14,17 +69,25 @@ void implSendLoginSuccess(String token) {
} catch (e) {
debugPrint('Failed to postMessage: $e');
}
// Close the popup after a short delay to ensure message sending
Timer(const Duration(milliseconds: 500), () {
html.window.close();
});
} else {
// Should not happen given isPopup check, but as fallback:
// Should not happen given isPopup check, but as fallback:
debugPrint('No opener found during popup flow.');
}
}
bool implIsPopup() {
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');
}

View File

@@ -15,7 +15,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
bool _isLoading = false;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
@@ -28,7 +28,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
// Show blocking dialog
final String? inputPassword = await showDialog<String>(
context: context,
@@ -86,7 +86,10 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red),
const SnackBar(
content: Text('Invalid Password. Access Denied.'),
backgroundColor: Colors.red,
),
);
context.go('/'); // Kick out
}
@@ -116,7 +119,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
}
}
String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim();
String? phone = _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
@@ -128,14 +133,21 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
await AuthProxyService.createUser(
loginId: loginId,
adminPassword: _verifiedAdminPassword!,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
email: _emailController.text.trim().isEmpty
? null
: _emailController.text.trim(),
phone: phone,
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
displayName: _nameController.text.trim().isEmpty
? null
: _nameController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green),
const SnackBar(
content: Text('User created successfully!'),
backgroundColor: Colors.green,
),
);
_formKey.currentState!.reset();
_loginIdController.clear();
@@ -158,9 +170,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
Widget build(BuildContext context) {
// Hide content until authorized
if (!_isAuthorized) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
@@ -186,7 +196,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _loginIdController,
decoration: const InputDecoration(
@@ -194,10 +204,12 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)",
),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
validator: (value) => value == null || value.isEmpty
? 'Please enter Login ID'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
@@ -207,7 +219,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
@@ -217,7 +229,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
),
const SizedBox(height: 16),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
@@ -228,14 +240,14 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _submit,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text("Create User"),
),
],
@@ -245,4 +257,4 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
),
);
}
}
}

View File

@@ -10,7 +10,8 @@ class UserManagementScreen extends StatefulWidget {
State<UserManagementScreen> createState() => _UserManagementScreenState();
}
class _UserManagementScreenState extends State<UserManagementScreen> with SingleTickerProviderStateMixin {
class _UserManagementScreenState extends State<UserManagementScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
@@ -23,7 +24,8 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- Create Tab Controllers ---
final _formKey = GlobalKey<FormState>();
final TextEditingController _createLoginIdController = TextEditingController();
final TextEditingController _createLoginIdController =
TextEditingController();
final TextEditingController _createEmailController = TextEditingController();
final TextEditingController _createPhoneController = TextEditingController();
final TextEditingController _createNameController = TextEditingController();
@@ -50,7 +52,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- Authentication ---
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
final String? inputPassword = await showDialog<String>(
context: context,
barrierDismissible: false,
@@ -64,15 +66,24 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: "Password", border: OutlineInputBorder()),
decoration: const InputDecoration(
labelText: "Password",
border: OutlineInputBorder(),
),
autofocus: true,
onSubmitted: (value) => Navigator.pop(context, value),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, passwordController.text), child: const Text("Enter")),
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, passwordController.text),
child: const Text("Enter"),
),
],
),
);
@@ -96,7 +107,12 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid Password'),
backgroundColor: Colors.red,
),
);
context.go('/');
}
}
@@ -107,7 +123,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
if (_verifiedAdminPassword == null) return;
setState(() => _isLoading = true);
try {
final users = await AuthProxyService.listUsers(_verifiedAdminPassword!, query: query);
final users = await AuthProxyService.listUsers(
_verifiedAdminPassword!,
query: query,
);
setState(() => _users = users);
} catch (e) {
_showError("Failed to load users: $e");
@@ -125,18 +144,23 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
Future<void> _deleteUser(String loginId) async {
if (_verifiedAdminPassword == null) return;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Delete User"),
content: Text("Are you sure you want to delete $loginId? This cannot be undone."),
content: Text(
"Are you sure you want to delete $loginId? This cannot be undone.",
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text("Delete")
onPressed: () => Navigator.pop(context, true),
child: const Text("Delete"),
),
],
),
@@ -158,11 +182,17 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
Future<void> _toggleStatus(String loginId, String currentStatus) async {
if (_verifiedAdminPassword == null) return;
final newStatus = (currentStatus == "enabled" || currentStatus == "active") ? "disabled" : "enabled";
final newStatus = (currentStatus == "enabled" || currentStatus == "active")
? "disabled"
: "enabled";
setState(() => _isLoading = true);
try {
await AuthProxyService.updateUserStatus(_verifiedAdminPassword!, loginId, newStatus);
await AuthProxyService.updateUserStatus(
_verifiedAdminPassword!,
loginId,
newStatus,
);
_showSuccess("User status updated to $newStatus");
_loadUsers(query: _searchController.text);
} catch (e) {
@@ -174,14 +204,20 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
Future<void> _editUser(Map user) async {
if (_verifiedAdminPassword == null) return;
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
if (loginId.isEmpty) return;
final nameController = TextEditingController(text: user['name'] ?? user['user']?['name'] ?? "");
final emailController = TextEditingController(text: user['user']?['email'] ?? "");
final phoneController = TextEditingController(text: user['user']?['phone'] ?? "");
final nameController = TextEditingController(
text: user['name'] ?? user['user']?['name'] ?? "",
);
final emailController = TextEditingController(
text: user['user']?['email'] ?? "",
);
final phoneController = TextEditingController(
text: user['user']?['phone'] ?? "",
);
final confirm = await showDialog<bool>(
context: context,
@@ -190,14 +226,29 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: "Name")),
TextField(controller: emailController, decoration: const InputDecoration(labelText: "Email")),
TextField(controller: phoneController, decoration: const InputDecoration(labelText: "Phone")),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: "Name"),
),
TextField(
controller: emailController,
decoration: const InputDecoration(labelText: "Email"),
),
TextField(
controller: phoneController,
decoration: const InputDecoration(labelText: "Phone"),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text("Save")),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("Cancel"),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("Save"),
),
],
),
);
@@ -206,7 +257,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
setState(() => _isLoading = true);
String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
String? phone = phoneController.text.trim().isEmpty
? null
: phoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
@@ -246,7 +299,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
}
}
String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim();
String? phone = _createPhoneController.text.trim().isEmpty
? null
: _createPhoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
@@ -258,9 +313,13 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
await AuthProxyService.createUser(
loginId: loginId,
adminPassword: _verifiedAdminPassword!,
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
email: _createEmailController.text.trim().isEmpty
? null
: _createEmailController.text.trim(),
phone: phone,
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(),
displayName: _createNameController.text.trim().isEmpty
? null
: _createNameController.text.trim(),
);
_showSuccess("User created successfully");
@@ -269,11 +328,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
_createEmailController.clear();
_createPhoneController.clear();
_createNameController.clear();
// Switch to list tab and reload
_tabController.animateTo(0);
_loadUsers();
} catch (e) {
_showError("Error: $e");
} finally {
@@ -284,12 +342,16 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
// --- UI Helpers ---
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
}
void _showSuccess(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
}
@override
@@ -315,10 +377,7 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
),
body: TabBarView(
controller: _tabController,
children: [
_buildUserListTab(),
_buildCreateUserTab(),
],
children: [_buildUserListTab(), _buildCreateUserTab()],
),
);
}
@@ -341,32 +400,49 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
),
),
Expanded(
child: _users.isEmpty
? const Center(child: Text("No users found."))
child: _users.isEmpty
? const Center(child: Text("No users found."))
: ListView.separated(
itemCount: _users.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final user = _users[index];
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID";
final name = user['name'] ?? user['user']?['name'] ?? "No Name";
final loginId = loginIDs.isNotEmpty
? loginIDs.first.toString()
: "Unknown ID";
final name =
user['name'] ?? user['user']?['name'] ?? "No Name";
final status = user['status'] ?? "unknown";
final isEnabled = status == "enabled" || status == "active";
return ListTile(
leading: CircleAvatar(
backgroundColor: isEnabled ? Colors.green.shade100 : Colors.grey.shade300,
child: Icon(Icons.person, color: isEnabled ? Colors.green : Colors.grey),
backgroundColor: isEnabled
? Colors.green.shade100
: Colors.grey.shade300,
child: Icon(
Icons.person,
color: isEnabled ? Colors.green : Colors.grey,
),
),
title: Text(name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loginId, style: const TextStyle(fontWeight: FontWeight.bold)),
Text("Status: $status", style: TextStyle(color: isEnabled ? Colors.green : Colors.red, fontSize: 12)),
Text(
loginId,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
"Status: $status",
style: TextStyle(
color: isEnabled ? Colors.green : Colors.red,
fontSize: 12,
),
),
],
),
trailing: Row(
@@ -378,7 +454,10 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
onPressed: () => _editUser(user),
),
IconButton(
icon: Icon(isEnabled ? Icons.block : Icons.check_circle, color: isEnabled ? Colors.orange : Colors.green),
icon: Icon(
isEnabled ? Icons.block : Icons.check_circle,
color: isEnabled ? Colors.orange : Colors.green,
),
tooltip: isEnabled ? "Disable User" : "Enable User",
onPressed: () => _toggleStatus(loginId, status),
),
@@ -417,27 +496,44 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)",
),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
validator: (value) => value == null || value.isEmpty
? 'Please enter Login ID'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _createNameController,
decoration: const InputDecoration(labelText: "Display Name", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person)),
decoration: const InputDecoration(
labelText: "Display Name",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _createEmailController,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)),
decoration: const InputDecoration(
labelText: "Email",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _createPhoneController,
decoration: const InputDecoration(labelText: "Phone Number", border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone), helperText: "010-xxxx-xxxx"),
decoration: const InputDecoration(
labelText: "Phone Number",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
helperText: "010-xxxx-xxxx",
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _createUserSubmit,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text("Create User"),
),
],

View File

@@ -134,13 +134,16 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 40),
if (_message != null)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
_message!,
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
style: TextStyle(
color: _success ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
@@ -155,7 +158,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
backgroundColor: Colors.blue,
),
),
if (!isLoggedIn && !_success)
Padding(
padding: const EdgeInsets.only(top: 16),

View File

@@ -17,7 +17,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
bool _isLoading = true;
bool _isSubmitting = false;
String? _error;
// 사용자가 선택한 스코프 목록
final Set<String> _selectedScopes = {};
@@ -41,8 +41,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async {
try {
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
final info = await AuthProxyService.getConsentInfo(
widget.consentChallenge,
);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) {
webWindow.redirectTo(info['redirectTo']);
@@ -52,19 +54,20 @@ class _ConsentScreenState extends State<ConsentScreen> {
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
if (info['scope_details'] != null) {
final details = info['scope_details'] as Map<String, dynamic>;
details.forEach((scope, detail) {
if (detail is Map<String, dynamic>) {
// 설명 업데이트
if (detail['description'] != null && detail['description'].toString().isNotEmpty) {
if (detail['description'] != null &&
detail['description'].toString().isNotEmpty) {
_scopeDescriptions[scope] = detail['description'].toString();
}
// 필수 여부 업데이트
if (detail['mandatory'] == true) {
_mandatoryScopes.add(scope);
} else {
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
if (scope != 'openid') {
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
}
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
final requestedScopes =
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
_selectedScopes.addAll(requestedScopes);
setState(() {
@@ -102,7 +106,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
widget.consentChallenge,
grantScope: _selectedScopes.toList(),
);
if (result['redirectTo'] != null) {
webWindow.redirectTo(result['redirectTo']);
} else {
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
if (confirmed == true) {
setState(() => _isSubmitting = true);
try {
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
final resp = await AuthProxyService.rejectConsent(
widget.consentChallenge,
);
final redirectTo = resp['redirectTo'];
if (redirectTo != null) {
webWindow.redirectTo(redirectTo);
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
} catch (e) {
setState(() => _isSubmitting = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
}
}
}
@@ -164,13 +170,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
Widget build(BuildContext context) {
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
return Scaffold(
backgroundColor: Colors.grey[100],
backgroundColor: Colors.grey[100],
body: Center(
child: _isLoading
? const CircularProgressIndicator()
: _error != null
? _buildErrorCard()
: _buildConsentCard(context),
? _buildErrorCard()
: _buildConsentCard(context),
),
);
}
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
final clientLogo = _consentInfo?['client']?['logo_uri'];
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
final requestedScopes =
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
[];
return SingleChildScrollView(
child: Container(
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
margin: const EdgeInsets.all(16),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
),
child: Row(
children: [
if (clientLogo != null && clientLogo.toString().isNotEmpty)
if (clientLogo != null &&
clientLogo.toString().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 16),
child: CircleAvatar(
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
children: [
const Text(
'요청된 권한',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'${requestedScopes.length}',
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
)
: const Text(
'동의하고 계속하기',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),

View File

@@ -32,30 +32,36 @@ class ErrorScreen extends StatelessWidget {
final title = isProd
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
: (hasCode
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
? tr(
'msg.userfront.error.title_with_code',
fallback: '오류: {{code}}',
params: {'code': normalizedCode},
)
: tr(
'msg.userfront.error.title_generic',
fallback: '오류가 발생했습니다',
));
final detail = isProd
? (isWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
: ((description?.isNotEmpty == true)
? description!
: (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
? description!
: (hasCode
? tr(
'msg.userfront.error.detail_generic',
fallback: '오류가 발생했습니다.',
)
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF111827),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
tr(
'ui.userfront.error.go_login',
fallback: '로그인으로 이동',
),
),
),
OutlinedButton(
onPressed: () => context.go('/'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: const BorderSide(color: Color(0xFFCBD5F5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),

View File

@@ -17,7 +17,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
@override
void initState() {
super.initState();
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
_drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
}
Future<void> _handlePasswordReset() async {
@@ -44,7 +46,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
setState(() => _isLoading = true);
try {
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
await AuthProxyService.initiatePasswordReset(
loginId,
drySend: _drySendEnabled,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -107,16 +112,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
children: [
Text(
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
if (_drySendEnabled) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
@@ -124,7 +129,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -132,7 +140,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
'msg.userfront.forgot.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
style: const TextStyle(
color: Color(0xFF8A6D3B),
fontSize: 12,
),
),
),
],
@@ -172,13 +183,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
tr(
'ui.userfront.forgot.submit',
fallback: '재설정 링크 전송',
),
tr('ui.userfront.forgot.submit', fallback: '재설정 링크 전송'),
),
),
],

View File

@@ -16,8 +16,14 @@ import '../../../core/services/web_window.dart';
class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken;
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
ConsumerState<LoginScreen> createState() => _LoginScreenState();
@@ -27,7 +33,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _linkIdController = TextEditingController();
final TextEditingController _passwordLoginIdController = TextEditingController();
final TextEditingController _passwordLoginIdController =
TextEditingController();
final TextEditingController _passwordController = TextEditingController();
String? _redirectUrl;
String? _loginChallenge;
@@ -40,8 +47,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
int _qrRemainingSeconds = 0;
Timer? _qrCountdownTimer;
int _qrPollIntervalMs = 2000;
final TextEditingController _shortCodePrefixController = TextEditingController();
final TextEditingController _shortCodeDigitsController = TextEditingController();
final TextEditingController _shortCodePrefixController =
TextEditingController();
final TextEditingController _shortCodeDigitsController =
TextEditingController();
String? _linkPendingRef;
String? _lastLinkLoginId;
bool _lastLinkIsEmail = true;
@@ -74,19 +83,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
super.initState();
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabSelection);
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
_drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
!AuthProxyService.isProdEnv;
_redirectUrl = widget.redirectUrl;
WidgetsBinding.instance.addPostFrameCallback((_) async {
final uri = Uri.base;
_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 codeParam = uri.queryParameters['code'];
final pendingRefParam = uri.queryParameters['pendingRef'];
final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
final hasShortCodePath =
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
final hasTokenParam = uri.queryParameters.containsKey('t');
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
final hasVerificationToken =
widget.verificationToken != null || hasTokenParam;
final hasLoginCode = loginIdParam != null && codeParam != null;
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
_verificationOnly =
hasVerificationToken || hasLoginCode || hasShortCodePath;
final notice = uri.queryParameters['notice'];
if (hasShortCodePath) {
@@ -114,10 +139,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (!mounted) return;
await _tryCookieSession();
}
if (uri.queryParameters.containsKey('redirect_url')) {
_redirectUrl = uri.queryParameters['redirect_url'];
}
});
}
@@ -143,9 +164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr(
'msg.userfront.login.cookie_check_failed',
fallback: '로그인 확인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
params: {'error': e.toString().replaceFirst('Exception: ', '')},
),
);
}
@@ -161,6 +180,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
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) {
context.go('/');
}
@@ -183,7 +215,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
try {
await AuthProxyService.checkCookieSession();
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory');
AuthTokenStore.setCookieMode(
provider: AuthTokenStore.getProvider() ?? 'ory',
);
await _acceptOidcLoginAndRedirect();
} catch (e) {
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
@@ -272,7 +306,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final parts = jwt.split('.');
if (parts.length != 3) return 'User';
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload);
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
} catch (e) {
@@ -343,65 +379,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _startQrPolling() {
_qrPollingTimer?.cancel();
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
timer.cancel();
return;
}
_qrPollingTimer = Timer.periodic(
Duration(milliseconds: _qrPollIntervalMs),
(timer) async {
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
timer.cancel();
return;
}
try {
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
if (res['error'] == 'slow_down') {
final interval = res['interval'];
if (interval is int && interval > 0) {
final nextIntervalMs = interval * 1000;
if (nextIntervalMs != _qrPollIntervalMs) {
_qrPollIntervalMs = nextIntervalMs;
try {
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
if (res['error'] == 'slow_down') {
final interval = res['interval'];
if (interval is int && interval > 0) {
final nextIntervalMs = interval * 1000;
if (nextIntervalMs != _qrPollIntervalMs) {
_qrPollIntervalMs = nextIntervalMs;
timer.cancel();
_startQrPolling();
return;
}
} else {
_qrPollIntervalMs += 500;
timer.cancel();
_startQrPolling();
return;
}
} else {
_qrPollIntervalMs += 500;
timer.cancel();
_startQrPolling();
}
if (res['error'] == 'authorization_pending') {
return;
}
}
if (res['error'] == 'authorization_pending') {
return;
}
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError(
tr(
'msg.userfront.login.qr_expired',
fallback: 'QR 세션이 만료되었습니다.',
),
);
return;
}
if (res['status'] == 'ok') {
timer.cancel();
_qrCountdownTimer?.cancel();
final token = res['sessionJwt'] ?? res['token'];
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
tr('msg.userfront.login.qr_expired', fallback: 'QR 세션이 만료되었습니다.'),
);
return;
}
if (res['status'] == 'ok') {
timer.cancel();
_qrCountdownTimer?.cancel();
final token = res['sessionJwt'] ?? res['token'];
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
}
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
}
} catch (e) {
debugPrint("[QR] Polling error: $e");
}
});
},
);
}
void _stopQrPolling() {
@@ -469,21 +505,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Duration redirectDelay = const Duration(seconds: 2),
}) {
if (!mounted) return;
final resolvedTitle = title ??
tr(
'ui.userfront.login.verification.title',
fallback: '승인 완료',
);
final resolvedPageTitle = pageTitle ??
tr(
'ui.userfront.login.verification.page_title',
fallback: '로그인 승인',
);
final resolvedActionLabel = actionLabel ??
tr(
'ui.userfront.login.verification.action_label',
fallback: '확인',
);
final resolvedTitle =
title ?? tr('ui.userfront.login.verification.title', fallback: '승인 완료');
final resolvedPageTitle =
pageTitle ??
tr('ui.userfront.login.verification.page_title', fallback: '로그인 승인');
final resolvedActionLabel =
actionLabel ??
tr('ui.userfront.login.verification.action_label', fallback: '확인');
setState(() {
_verificationApproved = true;
_verificationMessage = message;
@@ -507,11 +536,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
const Icon(
Icons.check_circle_outline,
color: Colors.green,
size: 72,
),
const SizedBox(height: 16),
Text(
_verificationTitle,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const SizedBox(height: 12),
Text(
@@ -527,7 +564,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 24),
FilledButton(
onPressed: () {
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
final hasLocalSession =
AuthTokenStore.getToken() != null ||
AuthTokenStore.usesCookie();
final target = hasLocalSession ? '/' : '/signin';
if (mounted) {
setState(() {
@@ -569,10 +608,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -585,18 +621,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
return;
}
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -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(' ', '+');
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
debugPrint(
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
);
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
@@ -636,16 +672,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
final jwt = res['sessionJwt'] ?? res['token'];
final status = res['status']?.toString();
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
debugPrint(
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
);
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -659,18 +694,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (_verificationOnly) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
_markVerificationApproved(
linkLoginMessage,
title: tr(
'ui.userfront.login.link.title',
fallback: '링크 로그인 완료',
),
title: tr('ui.userfront.login.link.title', fallback: '링크 로그인 완료'),
pageTitle: tr(
'ui.userfront.login.link.page_title',
fallback: '링크 로그인',
@@ -686,13 +715,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
debugPrint(
"[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e",
);
if (mounted) {
_showError(
tr(
@@ -730,10 +758,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
return;
}
@@ -747,10 +772,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
if (_verificationOnly) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
return;
}
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
@@ -758,10 +780,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved(
approvedMessage,
actionPath: actionPath,
);
_markVerificationApproved(approvedMessage, actionPath: actionPath);
}
} catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e");
@@ -819,7 +838,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
try {
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge);
final res = await AuthProxyService.loginWithPassword(
loginId,
password,
loginChallenge: _loginChallenge,
);
final jwt = res['sessionJwt'];
final provider = res['provider'] as String?;
final redirectTo = res['redirectTo'] as String?;
@@ -843,9 +866,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
tr(
'msg.userfront.login.password.failed',
fallback: '로그인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
params: {'error': e.toString().replaceFirst('Exception: ', '')},
),
);
}
@@ -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 {
if (mounted) {
showDialog(
context: context,
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 resendAfter = initResponse['resendAfter'];
final expiresIn = initResponse['expiresIn'];
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
debugPrint(
"[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider",
);
if (mounted) {
setState(() {
@@ -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;
const maxAttempts = 60;
var pollInterval = initialInterval ?? const Duration(seconds: 2);
@@ -1030,10 +1061,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
Navigator.of(context).pop();
_showError(
tr(
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
tr('msg.userfront.login.link_timeout', fallback: '로그인 요청 시간이 초과되었습니다.'),
);
}
}
@@ -1121,16 +1149,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
if (WebAuthIntegration.isPopup()) {
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
if (WebAuthIntegration.isPopup() ||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
debugPrint(
"[Auth] External integration detected (popup or redirect). Notifying...",
);
WebAuthIntegration.sendLoginSuccess(token);
} else {
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
final target = "$_redirectUrl?token=$token";
launchUrlString(target, webOnlyWindowName: '_self');
return;
}
return;
}
debugPrint("[Auth] Login success. Navigating to root.");
@@ -1213,7 +1238,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_drySendEnabled) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
@@ -1221,13 +1249,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const Icon(
Icons.warning_amber_rounded,
color: Color(0xFF8A6D3B),
),
const SizedBox(width: 8),
Expanded(
child: Text(
tr(
'msg.userfront.login.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
fallback:
'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(
color: Color(0xFF8A6D3B),
@@ -1283,7 +1315,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
prefixIcon: const Icon(
Icons.person_outline,
),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1297,7 +1331,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '비밀번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
prefixIcon: const Icon(
Icons.lock_outline,
),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1308,7 +1344,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
minimumSize: const Size.fromHeight(50),
),
child: Text(
tr('ui.userfront.login.action.submit', fallback: '로그인'),
tr(
'ui.userfront.login.action.submit',
fallback: '로그인',
),
),
),
],
@@ -1329,7 +1368,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
hintText: '',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
prefixIcon: const Icon(
Icons.person_outline,
),
),
onSubmitted: (_) => _handleLinkLogin(),
),
@@ -1352,7 +1393,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.link.helper',
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
@@ -1360,9 +1404,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Text(
tr(
'msg.userfront.login.link.short_code_help',
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
fallback:
'링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
@@ -1371,16 +1419,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded(
flex: 2,
child: TextField(
controller: _shortCodePrefixController,
textCapitalization: TextCapitalization.characters,
controller:
_shortCodePrefixController,
textCapitalization:
TextCapitalization.characters,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.short_code.prefix',
fallback: '영문 2자리',
),
border: const OutlineInputBorder(),
border:
const OutlineInputBorder(),
hintText: 'AB',
hintStyle: const TextStyle(color: Colors.grey),
hintStyle: const TextStyle(
color: Colors.grey,
),
),
maxLength: 2,
),
@@ -1389,22 +1442,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Expanded(
flex: 4,
child: TextField(
controller: _shortCodeDigitsController,
controller:
_shortCodeDigitsController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.short_code.digits',
fallback: '숫자 6자리',
),
border: const OutlineInputBorder(),
border:
const OutlineInputBorder(),
hintText: '345678',
hintStyle: const TextStyle(color: Colors.grey),
hintStyle: const TextStyle(
color: Colors.grey,
),
suffixText: _linkExpireSeconds > 0
? tr(
'ui.userfront.login.short_code.expire_time',
fallback: '유효시간 {{time}}',
params: {
'time': _formatTime(_linkExpireSeconds),
'time': _formatTime(
_linkExpireSeconds,
),
},
)
: null,
@@ -1417,13 +1476,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12),
FilledButton(
onPressed: () {
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
final digits = _shortCodeDigitsController.text.trim();
if (prefix.length != 2 || digits.length != 6) {
final prefix =
_shortCodePrefixController.text
.trim()
.toUpperCase();
final digits =
_shortCodeDigitsController.text
.trim();
if (prefix.length != 2 ||
digits.length != 6) {
_showError(
tr(
'msg.userfront.login.short_code.invalid',
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
fallback:
'문자 2개와 숫자 6자리를 입력해 주세요.',
),
);
return;
@@ -1447,27 +1513,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
fallback:
'재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) {
_showError(
tr(
'msg.userfront.login.link.missing_login_id',
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
fallback:
'이메일 또는 휴대폰 번호를 입력해 주세요.',
),
);
return;
}
_startEnchantedFlow(
loginId,
isEmail: _lastLinkIsEmail || loginId.contains('@'),
isEmail:
_lastLinkIsEmail ||
loginId.contains('@'),
codeOnly: false,
);
},
@@ -1477,7 +1551,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.resend_with_time',
fallback: '재발송 ({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
)
: tr(
@@ -1494,15 +1570,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
fallback:
'재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
final loginId =
_lastLinkLoginId ??
_linkIdController.text.trim();
if (loginId.isEmpty) {
_showError(
tr(
@@ -1523,7 +1604,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'ui.userfront.login.link.code_only',
fallback: '코드만 받기({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
'time': _formatTime(
_linkResendSeconds,
),
},
),
),
@@ -1542,13 +1625,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const CircularProgressIndicator()
else if (_qrImageBase64 != null)
Column(
crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(
12,
),
),
child: QrImageView(
data: _qrImageBase64!,
@@ -1559,20 +1647,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12),
Text(
_qrRemainingSeconds > 0
? tr(
'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}',
params: {
'time': _formatTime(_qrRemainingSeconds),
},
)
: tr(
'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
? tr(
'ui.userfront.login.qr.remaining',
fallback: '남은 시간: {{time}}',
params: {
'time': _formatTime(
_qrRemainingSeconds,
),
},
)
: tr(
'ui.userfront.login.qr.expired',
fallback: 'QR 코드 만료됨',
),
textAlign: TextAlign.center,
style: TextStyle(
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
color: _qrRemainingSeconds > 30
? Colors.blue
: Colors.red,
fontWeight: FontWeight.bold,
),
),
@@ -1583,7 +1675,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
fallback: '모바일 앱으로 스캔하세요',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
TextButton(
onPressed: _startQrFlow,
@@ -1629,7 +1724,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
'msg.userfront.login.no_account',
fallback: '계정이 없으신가요?',
),
style: const TextStyle(color: Colors.grey, fontSize: 14),
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
TextButton(
onPressed: () => context.push('/signup'),

View File

@@ -14,23 +14,27 @@ class LoginSuccessScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
const Icon(
Icons.check_circle_outline,
size: 80,
color: Colors.green,
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
tr(
'msg.userfront.login_success.subtitle',
fallback: '성공적으로 로그인되었습니다.',
),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
const SizedBox(height: 48),
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
FilledButton.icon(
onPressed: () {
@@ -38,12 +42,18 @@ class LoginSuccessScreen extends StatelessWidget {
},
icon: const Icon(Icons.camera_alt, size: 28),
label: Text(
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
tr(
'ui.userfront.login_success.qr',
fallback: 'QR 인증 (카메라 켜기)',
),
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700,
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),

View File

@@ -87,7 +87,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
Future<void> _onDetect(BarcodeCapture capture) async {
if (_isScanned) return;
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
if (barcode.rawValue != null) {
@@ -119,7 +119,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
final approveRef = qrData;
final storedToken = AuthTokenStore.getToken();
final sessionToken = storedToken;
var usesCookie = AuthTokenStore.usesCookie();
@@ -140,7 +140,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
token: sessionToken,
withCredentials: usesCookie,
);
if (mounted) {
setState(() {
_isSuccess = true;
@@ -226,7 +226,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
const SizedBox(height: 16),
Text(
title,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 12),
Text(
@@ -268,7 +272,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
controller: controller,
onDetect: _onDetect,
errorBuilder: (context, error) {
final isPermissionDenied = error.errorCode ==
final isPermissionDenied =
error.errorCode ==
MobileScannerErrorCode.permissionDenied;
return Center(
child: Column(
@@ -295,7 +300,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
: _requestCameraPermission,
child: Text(
_isRequestingCamera
? tr('ui.common.requesting', fallback: '요청 중...')
? tr(
'ui.common.requesting',
fallback: '요청 중...',
)
: tr(
'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기',

View File

@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
String? _loginId;
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
// 2. Fallback to URI query parameter if not available via router
if (_loginId == null || _loginId!.isEmpty) {
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
final uri = Uri.base;
_loginId = uri.queryParameters['loginId'];
}
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
// 토큰도 함께 읽어놓는다.
final uri = Uri.base;
_token = uri.queryParameters['token'];
_loadPolicy();
}
@@ -66,7 +67,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
if ((_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)) {
_showError(
tr(
'msg.userfront.reset.invalid_link',
@@ -163,9 +165,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
);
}
if (requiresNumber) {
parts.add(
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
);
parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
}
if (requiresSymbol) {
parts.add(
@@ -180,16 +180,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
),
title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
centerTitle: true,
),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
child:
(_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)
? _buildInvalidTokenView()
: Form(
key: _formKey,
@@ -227,7 +227,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
_isPasswordObscured
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
@@ -244,7 +246,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '비밀번호를 입력해주세요.',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minLength =
(_policy?['minLength'] as int?) ?? 12;
if (val.length < minLength) {
return tr(
'msg.userfront.reset.error.min_length',
@@ -262,7 +265,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (hasNumber) typeCount++;
if (hasSymbol) typeCount++;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
final minTypes =
(_policy?['minCharacterTypes'] as int?) ?? 0;
if (minTypes > 0 && typeCount < minTypes) {
return tr(
'msg.userfront.reset.error.min_types',
@@ -290,7 +294,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
);
}
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
if ((_policy?['nonAlphanumeric'] ?? true) &&
!hasSymbol) {
return tr(
'msg.userfront.reset.error.symbol',
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
@@ -312,11 +317,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
_isConfirmPasswordObscured
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
_isConfirmPasswordObscured =
!_isConfirmPasswordObscured;
});
},
),
@@ -369,8 +377,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16),
Text(
tr('msg.userfront.reset.invalid_title',
fallback: '유효하지 않은 링크입니다.'),
tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),

View File

@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
),
);
return;
}
setState(() { _isLoading = true; _emailError = null; });
setState(() {
_isLoading = true;
_emailError = null;
});
try {
final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
),
);
return;
}
await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email');
} catch (e) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
} finally {
setState(() => _isLoading = false);
}
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _emailCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
final success = await AuthProxyService.verifySignupCode(
_emailController.text.trim(),
'email',
code,
);
if (success) {
setState(() {
_isEmailVerified = true;
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
_emailError = null;
});
} else {
setState(() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
} catch (e) {
setState(() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
}
}
Future<void> _sendPhoneCode() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
setState(() { _isLoading = true; _phoneError = null; });
setState(() {
_isLoading = true;
_phoneError = null;
});
try {
await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone');
} catch (e) {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
),
);
} finally {
setState(() => _isLoading = false);
}
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _phoneCodeController.text.trim();
if (code.length != 6) return;
try {
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
final success = await AuthProxyService.verifySignupCode(
_phoneController.text.trim(),
'phone',
code,
);
if (success) {
setState(() {
_isPhoneVerified = true;
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneError = null;
});
} else {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
),
);
}
} catch (e) {
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
),
);
}
}
Future<void> _handleSignup() async {
if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
));
setState(
() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
),
);
return;
}
if (!_formKey.currentState!.validate()) return;
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
phone: _phoneController.text.trim(),
affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
department: _deptController.text.trim().isEmpty
? (_affiliationType == 'GENERAL' ? 'External' : '')
: _deptController.text.trim(),
termsAccepted: true,
);
if (mounted) _showSuccessDialog();
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
children: [
CircleAvatar(
radius: 12,
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)),
backgroundColor: isDone
? Colors.green
: (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone
? const Icon(Icons.check, size: 14, color: Colors.white)
: Text(
'$step',
style: TextStyle(
color: isCurrent ? Colors.white : Colors.black54,
fontSize: 10,
),
),
),
const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)),
Text(
label,
style: TextStyle(
fontSize: 9,
color: isCurrent ? Colors.black : Colors.grey,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
),
),
],
);
}
@@ -438,10 +489,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: CheckboxListTile(
title: Text(
tr(
'ui.userfront.signup.agreement.all',
fallback: '모두 동의합니다',
),
tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
value: _termsAccepted && _privacyAccepted,
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
return Column(
children: [
CheckboxListTile(
title: Text(title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
title: Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
value: value,
onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading,
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
child: SingleChildScrollView(
child: Text(
content,
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
height: 1.5,
),
),
),
),
@@ -517,8 +571,8 @@ class _SignupScreenState extends State<SignupScreen> {
}
static String get _tosText => tr(
'msg.userfront.signup.tos_full',
fallback: """
'msg.userfront.signup.tos_full',
fallback: """
바론 소프트웨어 이용약관
제1장 총칙
@@ -589,11 +643,11 @@ class _SignupScreenState extends State<SignupScreen> {
부칙
본 약관은 2024년 10월 1일부터 시행됩니다.
""",
);
);
static String get _privacyText => tr(
'msg.userfront.signup.privacy_full',
fallback: """
'msg.userfront.signup.privacy_full',
fallback: """
개인정보 수집 및 이용 동의
바론서비스 개인정보처리방침
@@ -702,7 +756,7 @@ class _SignupScreenState extends State<SignupScreen> {
제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
""",
);
);
Widget _buildStepAuth() {
return Column(
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 가족사 이메일 안내 문구
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
@@ -730,7 +787,11 @@ class _SignupScreenState extends State<SignupScreen> {
'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
),
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
),
],
@@ -753,7 +814,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.auth.email.label',
fallback: '이메일 주소',
),
border: const OutlineInputBorder(),
border: const OutlineInputBorder(),
errorText: _emailError,
hintText: 'example@hanmaceng.co.kr',
),
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
onPressed: (_isEmailVerified || _isLoading)
? null
: _sendEmailCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text(
_emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyEmailCode();
},
),
],
if (_isEmailVerified)
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox(
height: 55,
child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
onPressed: (_isPhoneVerified || _isLoading)
? null
: _sendPhoneCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text(
_phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyPhoneCode();
},
),
],
if (_isPhoneVerified)
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _nameController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.name',
fallback: '이름',
),
labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
border: const OutlineInputBorder(),
),
),
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
DropdownMenuItem(
value: 'GENERAL',
child: Text(
tr(
'domain.affiliation.general',
fallback: '일반 사용자',
),
tr('domain.affiliation.general', fallback: '일반 사용자'),
),
),
DropdownMenuItem(
value: 'AFFILIATE',
child: Text(
tr(
'domain.affiliation.affiliate',
fallback: '가족사 임직원',
),
tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
),
),
],
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
items: [
DropdownMenuItem(
value: 'HANMAC',
child: Text(
tr('domain.company.hanmac', fallback: '한맥'),
),
child: Text(tr('domain.company.hanmac', fallback: '한맥')),
),
DropdownMenuItem(
value: 'SAMAN',
child: Text(
tr('domain.company.saman', fallback: '삼안'),
),
child: Text(tr('domain.company.saman', fallback: '삼안')),
),
DropdownMenuItem(
value: 'PTC',
child: Text(
tr('domain.company.ptc', fallback: 'PTC'),
),
child: Text(tr('domain.company.ptc', fallback: 'PTC')),
),
DropdownMenuItem(
value: 'JANGHEON',
child: Text(
tr('domain.company.jangheon', fallback: '장헌'),
),
child: Text(tr('domain.company.jangheon', fallback: '장헌')),
),
DropdownMenuItem(
value: 'BARON',
child: Text(
tr('domain.company.baron', fallback: '바론'),
),
child: Text(tr('domain.company.baron', fallback: '바론')),
),
DropdownMenuItem(
value: 'HALLA',
child: Text(
tr('domain.company.halla', fallback: '한라'),
),
child: Text(tr('domain.company.halla', fallback: '한라')),
),
],
onChanged: _isAffiliateEmail
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)',
),
border: const OutlineInputBorder()
border: const OutlineInputBorder(),
),
),
],
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
);
}
if (requiresUpper) {
parts.add(
tr(
'msg.userfront.signup.policy.uppercase',
fallback: '대문자',
),
);
parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
}
if (requiresLower) {
parts.add(
tr(
'msg.userfront.signup.policy.lowercase',
fallback: '소문자',
),
);
parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
}
if (requiresNumber) {
parts.add(
tr(
'msg.userfront.signup.policy.number',
fallback: '숫자',
),
);
parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
}
if (requiresSymbol) {
parts.add(
tr(
'msg.userfront.signup.policy.symbol',
fallback: '특수문자',
),
);
parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
}
return tr(
@@ -1117,7 +1159,7 @@ class _SignupScreenState extends State<SignupScreen> {
Widget _buildStepPassword() {
String p = _passwordController.text;
// Default Policy Fallback
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 비밀번호 정책 안내 박스
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.security, size: 18, color: Colors.blue),
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded(
child: Text(
_buildPolicyDescription(),
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
style: TextStyle(
fontSize: 12,
color: Colors.blue[800],
fontWeight: FontWeight.w500,
),
),
),
],
@@ -1219,10 +1268,7 @@ class _SignupScreenState extends State<SignupScreen> {
),
if (requiresNumber)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.number',
fallback: '숫자',
),
tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
hasDigit,
),
if (requiresSymbol)
@@ -1253,7 +1299,7 @@ class _SignupScreenState extends State<SignupScreen> {
labelText: tr(
'ui.userfront.signup.password.confirm_label',
fallback: '비밀번호 확인',
),
),
border: const OutlineInputBorder(),
errorText: _confirmPasswordError,
),
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
Icon(
isValid ? Icons.check_circle : Icons.circle_outlined,
size: 14,
color: isValid ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)),
Text(
label,
style: TextStyle(
fontSize: 11,
color: isValid ? Colors.green : Colors.grey,
),
),
],
);
}
@@ -1276,8 +1332,10 @@ class _SignupScreenState extends State<SignupScreen> {
@override
Widget build(BuildContext context) {
bool canGoNext = false;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
canGoNext = true;
if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty;
if (_affiliationType == 'GENERAL') {
@@ -1313,11 +1371,13 @@ class _SignupScreenState extends State<SignupScreen> {
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: _currentStep == 1
? _buildStepAgreement()
: (_currentStep == 2
? _buildStepAuth()
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
child: _currentStep == 1
? _buildStepAgreement()
: (_currentStep == 2
? _buildStepAuth()
: (_currentStep == 3
? _buildStepInfo()
: _buildStepPassword())),
),
),
),
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(55),
side: const BorderSide(color: Colors.black),
),
child: Text(
tr('ui.common.prev', fallback: '이전'),
style: const TextStyle(color: Colors.black),
@@ -1340,20 +1403,35 @@ class _SignupScreenState extends State<SignupScreen> {
],
Expanded(
child: FilledButton(
onPressed: _currentStep < 4
? (canGoNext ? () => setState(() => _currentStep++) : null)
: (_isLoading ? null : _handleSignup),
onPressed: _currentStep < 4
? (canGoNext
? () => setState(() => _currentStep++)
: null)
: (_isLoading ? null : _handleSignup),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55),
backgroundColor: Colors.black,
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(
_currentStep < 4
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
_currentStep < 4
? tr(
'ui.userfront.signup.next_step',
fallback: '다음 단계',
)
: tr(
'ui.userfront.signup.complete',
fallback: '가입 완료',
),
),
),
),
],

View File

@@ -18,22 +18,19 @@ String _envOrDefault(String key, String fallback) {
String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
final queryParameters = <String, String>{
'limit': '20',
};
final queryParameters = <String, String>{'limit': '20'};
if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor;
}
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline')
.replace(queryParameters: queryParameters);
final url = Uri.parse(
'$_baseUrl/api/v1/audit/auth/timeline',
).replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -60,10 +57,6 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
}
}
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
@@ -86,11 +79,11 @@ class AuthTimelineState {
});
const AuthTimelineState.initial()
: items = const [],
nextCursor = null,
isLoading = false,
isLoadingMore = false,
error = null;
: items = const [],
nextCursor = null,
isLoading = false,
isLoadingMore = false,
error = null;
AuthTimelineState copyWith({
List<AuditLogEntry>? items,
@@ -188,6 +181,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
}
}
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
AuthTimelineNotifier.new,
);
final authTimelineProvider =
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
AuthTimelineNotifier.new,
);

View File

@@ -64,14 +64,12 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
try {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -85,7 +83,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
return items
.whereType<Map<String, dynamic>>()
.map(LinkedRp.fromJson)
@@ -106,6 +104,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
}
}
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
return LinkedRpsNotifier();
});
final linkedRpsProvider =
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
return LinkedRpsNotifier();
});

View File

@@ -68,8 +68,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
content: Text(
tr(
'msg.userfront.dashboard.revoke.confirm',
fallback:
'{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
fallback: '{{app}} 앱과의 연동을 해지하시겠습니까?\\n해지하면 다음 로그인 시 다시 동의가 필요합니다.',
params: {'app': appName},
),
),
@@ -81,8 +80,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child:
Text(tr('ui.userfront.dashboard.revoke.confirm_button', fallback: '해지하기')),
child: Text(
tr(
'ui.userfront.dashboard.revoke.confirm_button',
fallback: '해지하기',
),
),
),
],
),
@@ -158,31 +161,45 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('ui.userfront.dashboard.scopes.title',
fallback: '권한 (Scopes)'),
tr(
'ui.userfront.dashboard.scopes.title',
fallback: '권한 (Scopes)',
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (item.scopes.isEmpty)
Text(
tr('msg.userfront.dashboard.scopes.empty',
fallback: '요청된 권한이 없습니다.'),
tr(
'msg.userfront.dashboard.scopes.empty',
fallback: '요청된 권한이 없습니다.',
),
style: const TextStyle(color: Colors.grey),
)
else
Wrap(
spacing: 8,
runSpacing: 4,
children: item.scopes.map((s) => Chip(
label: Text(s, style: const TextStyle(fontSize: 12)),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)).toList(),
children: item.scopes
.map(
(s) => Chip(
label: Text(
s,
style: const TextStyle(fontSize: 12),
),
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
)
.toList(),
),
const SizedBox(height: 24),
Text(
tr('ui.userfront.dashboard.status_history',
fallback: '상태 이력'),
tr(
'ui.userfront.dashboard.status_history',
fallback: '상태 이력',
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -200,10 +217,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Builder(
builder: (context) {
final statusLabel = item.status == 'active'
? tr('ui.common.status.active',
fallback: '활성')
: tr('ui.userfront.dashboard.status.revoked',
fallback: '해지됨');
? tr('ui.common.status.active', fallback: '활성')
: tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
);
return Text(
tr(
'msg.userfront.dashboard.current_status',
@@ -242,8 +260,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title:
Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
selected: true,
onTap: () {
if (closeOnTap) {
@@ -254,8 +271,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
ListTile(
leading: const Icon(Icons.person_outline),
title:
Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
@@ -265,8 +281,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title:
Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
onTap: () {
if (closeOnTap) {
Navigator.of(context).pop();
@@ -277,8 +292,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title:
Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
onTap: () async {
if (closeOnTap) {
Navigator.of(context).pop();
@@ -297,12 +311,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_revokedClientIds.clear();
});
ref.invalidate(linkedRpsProvider);
await Future.wait([
ref.read(linkedRpsProvider.future),
ref.read(authTimelineProvider.notifier).refresh(),
]);
await _loadAuditLogs(reset: true);
await ref.read(linkedRpsProvider.notifier).refresh();
}
@@ -316,21 +330,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<AuditPage> _fetchAuditLogs({String? cursor}) async {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final queryParameters = <String, String>{
'limit': '20',
};
final queryParameters = <String, String>{'limit': '20'};
if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor;
}
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline')
.replace(queryParameters: queryParameters);
final url = Uri.parse(
'$baseUrl/api/v1/audit/auth/timeline',
).replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -401,11 +412,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
if (parts.length != 3) {
return null;
}
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final payload = utf8.decode(
base64Url.decode(base64Url.normalize(parts[1])),
);
final data = json.decode(payload) as Map<String, dynamic>;
final iatValue = data['iat'] ?? data['auth_time'];
if (iatValue is num) {
return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal();
return DateTime.fromMillisecondsSinceEpoch(
iatValue.toInt() * 1000,
).toLocal();
}
} catch (_) {
return null;
@@ -467,9 +482,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) {
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedUserAgent =
log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
final hasApproverMeta =
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText(authMethod);
}
@@ -497,7 +514,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
);
}
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false)
final approvedSessionId =
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
false)
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc
@@ -544,8 +563,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
style: TextStyle(
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
decoration:
approvedSessionId.isEmpty ? null : TextDecoration.underline,
decoration: approvedSessionId.isEmpty
? null
: TextDecoration.underline,
),
),
),
@@ -555,9 +575,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
final isOidc = authMethod.contains('OIDC');
if (authMethod != 'QR' && !isOidc) {
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedUserAgent =
log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
final hasApproverMeta =
approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText(
tr(
@@ -595,7 +617,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
);
}
final approvedSessionId = (log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ?? false)
final approvedSessionId =
(log.detailMap['approved_session_id']?.toString().trim().isNotEmpty ??
false)
? log.detailMap['approved_session_id'].toString()
: log.sessionId;
final tooltipLabel = isOidc
@@ -642,7 +666,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'msg.userfront.dashboard.auth_method',
fallback: '인증수단: {{method}}',
params: {
'method': isOidc ? authMethod : tr('ui.common.qr', fallback: 'QR'),
'method': isOidc
? authMethod
: tr('ui.common.qr', fallback: 'QR'),
},
),
style: TextStyle(
@@ -714,7 +740,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final profileState = ref.watch(profileProvider);
final profile = profileState.value;
final timelineState = ref.watch(authTimelineProvider);
final userName = profile?.name ??
final userName =
profile?.name ??
profile?.email ??
profile?.phone ??
tr('ui.userfront.profile.user_fallback', fallback: 'User');
@@ -751,7 +778,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
],
),
drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
drawer: isWide
? null
: Drawer(child: _buildSideMenu(context, closeOnTap: true)),
body: Row(
children: [
if (isWide)
@@ -775,11 +804,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMobile) ...[
_buildHeaderCard(userName, department, sessionIssuedAt),
_buildHeaderCard(
userName,
department,
sessionIssuedAt,
),
const SizedBox(height: 28),
],
_buildSectionTitle(
tr('ui.userfront.sections.apps', fallback: '나의 App 현황'),
tr(
'ui.userfront.sections.apps',
fallback: '나의 App 현황',
),
tr(
'msg.userfront.sections.apps_subtitle',
fallback: '현재 연결된 앱과 최근 인증 상태입니다.',
@@ -810,7 +846,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) {
Widget _buildHeaderCard(
String userName,
String department,
DateTime? issuedAt,
) {
final sessionLabel = issuedAt != null
? _formatDateTime(issuedAt)
: tr('ui.userfront.session.unknown', fallback: '알 수 없음');
@@ -823,7 +863,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
fallback: '안녕하세요, {{name}}님',
params: {'name': userName},
),
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: _ink),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: _ink,
),
),
const SizedBox(height: 6),
Text(
@@ -871,13 +915,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: [
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
),
const SizedBox(width: 12),
Text(
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
],
);
}
@@ -897,7 +942,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
style: const TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
),
),
],
),
@@ -920,7 +969,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
'msg.userfront.dashboard.activities.empty',
fallback: '연동된 앱이 없습니다.',
),
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
@@ -959,14 +1012,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
final items = <_ActivityItem>[];
for (final rp in linkedRps) {
final normalizedStatus = rp.status.toLowerCase();
// status가 'inactive'로 내려올 수 있으므로 이를 반영
final isActiveInApi = normalizedStatus == 'active' || normalizedStatus == '';
final isActiveInApi =
normalizedStatus == 'active' || normalizedStatus == '';
final isRevoked = !isActiveInApi;
final lastAuthLabel = rp.lastAuthenticatedAt != null
@@ -975,7 +1027,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final statusCode = isRevoked ? 'revoked' : 'active';
final name = rp.name.isNotEmpty ? rp.name : rp.id;
items.add(
_ActivityItem(
clientId: rp.id,
@@ -995,17 +1047,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
items.sort((a, b) {
final aActive = a.status == 'active';
final bActive = b.status == 'active';
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
// 둘 다 활성이거나 둘 다 비활성인 경우 최근 인증순 내림차순
if (a.lastAuthDateTime != null && b.lastAuthDateTime != null) {
return b.lastAuthDateTime!.compareTo(a.lastAuthDateTime!);
}
if (a.lastAuthDateTime != null) return -1;
if (b.lastAuthDateTime != null) return 1;
return 0;
});
@@ -1018,7 +1070,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
// 화면 너비에 따른 컬럼 수 및 초기 표시 개수 결정
int crossAxisCount;
if (maxWidth > 1200) {
@@ -1042,7 +1094,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
// 카드의 너비를 화면 너비에 맞춰 계산 (여백 고려)
final double spacing = 12.0;
final double cardWidth = (maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
final double cardWidth =
(maxWidth - (spacing * (crossAxisCount - 1))) / crossAxisCount;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1063,18 +1116,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => setState(() => _showAllActivities = !_showAllActivities),
onPressed: () => setState(
() => _showAllActivities = !_showAllActivities,
),
icon: Icon(
_showAllActivities ? Icons.keyboard_arrow_up : Icons.add,
size: 18,
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
color: _showAllActivities
? Colors.grey
: Colors.blueAccent,
),
label: Text(
_showAllActivities
? tr('ui.common.collapse', fallback: '접기')
: tr('ui.common.show_more', fallback: '+ 더보기'),
style: TextStyle(
color: _showAllActivities ? Colors.grey : Colors.blueAccent,
color: _showAllActivities
? Colors.grey
: Colors.blueAccent,
fontWeight: FontWeight.bold,
),
),
@@ -1090,7 +1149,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildActivityCard(_ActivityItem item, {double? cardWidth}) {
final isActive = item.status == 'active';
final statusColor = isActive ? Colors.green : Colors.grey;
final borderColor = isActive ? Colors.green.withValues(alpha: 128) : _border;
final borderColor = isActive
? Colors.green.withValues(alpha: 128)
: _border;
final borderWidth = isActive ? 1.5 : 1.0;
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
@@ -1104,13 +1165,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
color: _surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor, width: borderWidth),
boxShadow: isActive ? [
BoxShadow(
color: Colors.green.withValues(alpha: 13),
blurRadius: 10,
offset: const Offset(0, 4),
)
] : null,
boxShadow: isActive
? [
BoxShadow(
color: Colors.green.withValues(alpha: 13),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1120,7 +1183,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded(
child: Text(
item.appName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
Container(
@@ -1132,8 +1199,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: Text(
item.status == 'active'
? tr('ui.common.status.active', fallback: '활성')
: tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨'),
style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600),
: tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
),
style: TextStyle(
fontSize: 11,
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
],
@@ -1146,7 +1220,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(height: 4),
Text(
item.lastAuthAt,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 16),
Row(
@@ -1168,22 +1246,38 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke,
onPressed: (_isRevoking || item.isRevoked)
? null
: item.onRevoke,
style: OutlinedButton.styleFrom(
foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent,
side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5),
foregroundColor: item.isRevoked
? Colors.grey
: Colors.redAccent,
side: BorderSide(
color: item.isRevoked ? Colors.grey : Colors.redAccent,
width: 0.5,
),
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: _isRevoking && !item.isRevoked
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.redAccent,
),
)
: Text(
item.isRevoked
? tr('ui.userfront.dashboard.status.revoked', fallback: '해지됨')
: tr('ui.userfront.dashboard.revoke.title', fallback: '연동 해지'),
? tr(
'ui.userfront.dashboard.status.revoked',
fallback: '해지됨',
)
: tr(
'ui.userfront.dashboard.revoke.title',
fallback: '연동 해지',
),
style: const TextStyle(fontSize: 13),
),
),
@@ -1268,7 +1362,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const SizedBox(height: 8),
TextButton(
onPressed: () => ref.read(authTimelineProvider.notifier).refresh(),
onPressed: () =>
ref.read(authTimelineProvider.notifier).refresh(),
child: Text(tr('ui.common.retry', fallback: '다시 시도')),
),
],
@@ -1326,7 +1421,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
columns: [
DataColumn(
label: Text(
tr('ui.userfront.audit.table.session_id', fallback: 'Session ID'),
tr(
'ui.userfront.audit.table.session_id',
fallback: 'Session ID',
),
),
),
DataColumn(
@@ -1336,7 +1434,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.app', fallback: '애플리케이션'),
tr(
'ui.userfront.audit.table.app',
fallback: '애플리케이션',
),
),
),
DataColumn(
@@ -1346,17 +1447,26 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.device', fallback: '접속환경'),
tr(
'ui.userfront.audit.table.device',
fallback: '접속환경',
),
),
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.auth_method', fallback: '인증수단'),
tr(
'ui.userfront.audit.table.auth_method',
fallback: '인증수단',
),
),
),
DataColumn(
label: Text(
tr('ui.userfront.audit.table.result', fallback: '인증결과'),
tr(
'ui.userfront.audit.table.result',
fallback: '인증결과',
),
),
),
DataColumn(
@@ -1369,47 +1479,57 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final statusLabel = log.status == 'success'
? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패');
final statusColor =
log.status == 'success' ? Colors.green : Colors.redAccent;
final statusColor = log.status == 'success'
? Colors.green
: Colors.redAccent;
final authMethod = log.authMethod.isNotEmpty
? log.authMethod
: _authMethodLabel();
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
return DataRow(cells: [
DataCell(
_selectableText(
log.sessionId.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.sessionId,
),
),
DataCell(_selectableText(_formatDateTime(log.timestamp))),
DataCell(_buildAppCell(log)),
DataCell(
_selectableText(
log.ipAddress.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.ipAddress,
),
),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(
_selectableText(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
final deviceLabel = _deviceLabelFromUserAgent(
log.userAgent,
);
return DataRow(
cells: [
DataCell(
_selectableText(
log.sessionId.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.sessionId,
),
),
),
DataCell(
_selectableText(
tr('ui.userfront.audit.table.pending', fallback: '(준비중)'),
style: const TextStyle(color: Colors.grey),
DataCell(
_selectableText(_formatDateTime(log.timestamp)),
),
),
]);
DataCell(_buildAppCell(log)),
DataCell(
_selectableText(
log.ipAddress.isEmpty
? tr('ui.common.hyphen', fallback: '-')
: log.ipAddress,
),
),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(
_selectableText(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
DataCell(
_selectableText(
tr(
'ui.userfront.audit.table.pending',
fallback: '(준비중)',
),
style: const TextStyle(color: Colors.grey),
),
),
],
);
}).toList(),
),
),
@@ -1443,7 +1563,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Expanded(
child: _buildAppCell(
log,
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
style: const TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
_selectableText(
@@ -1451,7 +1574,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
? tr('ui.common.status.success', fallback: '성공')
: tr('ui.common.status.failure', fallback: '실패'),
style: TextStyle(
color: log.status == 'success' ? Colors.green : Colors.redAccent,
color: log.status == 'success'
? Colors.green
: Colors.redAccent,
fontWeight: FontWeight.w600,
),
),
@@ -1491,10 +1616,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
tr(
'msg.userfront.audit.device',
fallback: '접속환경: {{value}}',
params: {'value': _deviceLabelFromUserAgent(log.userAgent)},
params: {
'value': _deviceLabelFromUserAgent(log.userAgent),
},
),
),
_buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()),
_buildAuthMethodLine(
log,
log.authMethod.isNotEmpty
? log.authMethod
: _authMethodLabel(),
),
_selectableText(
tr(
'msg.userfront.audit.result',
@@ -1507,10 +1639,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
_selectableText(
tr(
'msg.userfront.audit.status',
fallback: '현황: (준비중)',
),
tr('msg.userfront.audit.status', fallback: '현황: (준비중)'),
style: TextStyle(color: Colors.grey[600]),
),
],
@@ -1542,7 +1671,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
TextButton(
onPressed: () => ref.read(authTimelineProvider.notifier).loadMore(),
onPressed: () =>
ref.read(authTimelineProvider.notifier).loadMore(),
child: Text(tr('ui.common.retry', fallback: '재시도')),
),
],

View File

@@ -21,12 +21,7 @@ class Tenant {
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'slug': slug,
'description': description,
};
return {'id': id, 'name': name, 'slug': slug, 'description': description};
}
}
@@ -62,7 +57,9 @@ class UserProfile {
department: json['department'] ?? '',
affiliationType: json['affiliationType'] ?? '',
companyCode: json['companyCode'] ?? '',
metadata: json['metadata'] != null ? Map<String, dynamic>.from(json['metadata']) : null,
metadata: json['metadata'] != null
? Map<String, dynamic>.from(json['metadata'])
: null,
tenant: json['tenant'] != null ? Tenant.fromJson(json['tenant']) : null,
);
}
@@ -81,11 +78,7 @@ class UserProfile {
};
}
UserProfile copyWith({
String? name,
String? phone,
String? department,
}) {
UserProfile copyWith({String? name, String? phone, String? department}) {
return UserProfile(
id: id,
email: email,

View File

@@ -13,7 +13,8 @@ class ProfileRepository {
return dotenv.env[key] ?? fallback;
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static String get _baseUrl =>
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
// Helper to get session token
static Future<String?> _getToken() async {
@@ -31,9 +32,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -68,9 +67,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -107,9 +104,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -145,9 +140,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/password');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
@@ -183,9 +176,7 @@ class ProfileRepository {
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{
'Content-Type': 'application/json',
};
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}

View File

@@ -32,20 +32,20 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
}) async {
// Show loading state
state = const AsyncValue.loading();
// Perform update and then re-fetch profile
state = await AsyncValue.guard(() async {
await ref.read(profileRepositoryProvider).updateMyProfile(
name: name,
phone: phone,
department: department,
);
await ref
.read(profileRepositoryProvider)
.updateMyProfile(name: name, phone: phone, department: department);
return _fetch();
});
}
}
// 3. Provider definition
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(() {
return ProfileNotifier();
});
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
() {
return ProfileNotifier();
},
);

View File

@@ -140,7 +140,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (_editingField != 'name' && _nameController!.text != profile.name) {
_nameController!.text = profile.name;
}
if (_editingField != 'department' && _departmentController!.text != profile.department) {
if (_editingField != 'department' &&
_departmentController!.text != profile.department) {
_departmentController!.text = profile.department;
}
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
@@ -274,10 +275,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.phone.verified',
fallback: '인증되었습니다.',
),
tr('msg.userfront.profile.phone.verified', fallback: '인증되었습니다.'),
),
),
);
@@ -310,24 +308,30 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final confirmPassword = _confirmPasswordController?.text.trim() ?? '';
if (currentPassword.isEmpty) {
setState(() => _passwordError = tr(
'msg.userfront.profile.password.current_required',
fallback: '현재 비밀번호를 입력해 주세요.',
));
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.current_required',
fallback: '현재 비밀번호를 입력해 주세요.',
),
);
return;
}
if (newPassword.isEmpty) {
setState(() => _passwordError = tr(
'msg.userfront.profile.password.new_required',
fallback: '새 비밀번호를 입력해 주세요.',
));
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.new_required',
fallback: '새 비밀번호를 입력해 주세요.',
),
);
return;
}
if (newPassword != confirmPassword) {
setState(() => _passwordError = tr(
'msg.userfront.profile.password.mismatch',
fallback: '새 비밀번호가 일치하지 않습니다.',
));
setState(
() => _passwordError = tr(
'msg.userfront.profile.password.mismatch',
fallback: '새 비밀번호가 일치하지 않습니다.',
),
);
return;
}
@@ -338,7 +342,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
});
try {
await ref.read(profileRepositoryProvider).changePassword(
await ref
.read(profileRepositoryProvider)
.changePassword(
currentPassword: currentPassword,
newPassword: newPassword,
);
@@ -434,10 +440,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
'msg.userfront.profile.name_required',
fallback: '이름을 입력해주세요.',
),
tr('msg.userfront.profile.name_required', fallback: '이름을 입력해주세요.'),
),
),
);
@@ -500,7 +503,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_isSavingField = true;
try {
await ref.read(profileProvider.notifier).updateProfile(
await ref
.read(profileProvider.notifier)
.updateProfile(
name: nextName,
phone: nextPhone,
department: nextDepartment,
@@ -551,32 +556,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
ListTile(
leading: const Icon(Icons.home_outlined),
title: Text(
tr('ui.userfront.nav.dashboard', fallback: '대시보드'),
),
title: Text(tr('ui.userfront.nav.dashboard', fallback: '대시보드')),
onTap: () => context.go('/'),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(
tr('ui.userfront.nav.profile', fallback: '내 정보'),
),
title: Text(tr('ui.userfront.nav.profile', fallback: '내 정보')),
selected: true,
onTap: () => context.go('/profile'),
),
ListTile(
leading: const Icon(Icons.qr_code_scanner),
title: Text(
tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔'),
),
title: Text(tr('ui.userfront.nav.qr_scan', fallback: 'QR 스캔')),
onTap: () => context.go('/scan'),
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: Text(
tr('ui.userfront.nav.logout', fallback: '로그아웃'),
),
title: Text(tr('ui.userfront.nav.logout', fallback: '로그아웃')),
onTap: _logout,
),
],
@@ -589,13 +586,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
),
),
const SizedBox(width: 12),
Text(
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
Text(subtitle, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
],
);
}
@@ -615,7 +613,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600),
style: const TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
),
),
],
),
@@ -650,10 +652,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
child: Row(
children: [
const CircleAvatar(
radius: 32,
child: Icon(Icons.person, size: 32),
),
const CircleAvatar(radius: 32, child: Icon(Icons.person, size: 32)),
const SizedBox(width: 16),
Expanded(
child: Column(
@@ -672,7 +671,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
),
const SizedBox(height: 6),
Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
Text(
email,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
@@ -682,7 +684,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
Icons.badge_outlined,
tr('ui.userfront.profile.manage', fallback: '프로필 관리'),
),
_buildInfoChip(Icons.apartment, profile.tenant?.name ?? department),
_buildInfoChip(
Icons.apartment,
profile.tenant?.name ?? department,
),
],
),
],
@@ -787,9 +792,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (!isEditing) {
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
tr('ui.userfront.profile.phone.title', fallback: '전화번호'),
),
title: Text(tr('ui.userfront.profile.phone.title', fallback: '전화번호')),
subtitle: Text(displayValue),
trailing: TextButton(
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
@@ -918,7 +921,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility),
icon: Icon(
_showCurrentPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(() {
_showCurrentPassword = !_showCurrentPassword;
}),
@@ -936,7 +943,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility),
icon: Icon(
_showNewPassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() {
_showNewPassword = !_showNewPassword;
}),
@@ -954,7 +963,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility),
icon: Icon(
_showConfirmPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(() {
_showConfirmPassword = !_showConfirmPassword;
}),
@@ -963,10 +976,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
if (_passwordError != null) ...[
const SizedBox(height: 12),
Text(
_passwordError!,
style: const TextStyle(color: Colors.red),
),
Text(_passwordError!, style: const TextStyle(color: Colors.red)),
],
if (_passwordSuccess != null) ...[
const SizedBox(height: 12),
@@ -1037,7 +1047,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
_buildEditableTile(
field: 'name',
label: tr('ui.userfront.profile.field.name', fallback: '이름'),
label: tr(
'ui.userfront.profile.field.name',
fallback: '이름',
),
value: profile.name,
profile: profile,
isUpdating: isUpdating,
@@ -1045,7 +1058,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const Divider(height: 24),
_buildReadOnlyTile(
tr('ui.userfront.profile.field.email', fallback: '이메일'),
tr(
'ui.userfront.profile.field.email',
fallback: '이메일',
),
profile.email,
),
const Divider(height: 24),
@@ -1055,7 +1071,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.profile.section.organization', fallback: '조직 정보'),
tr(
'ui.userfront.profile.section.organization',
fallback: '조직 정보',
),
tr(
'msg.userfront.profile.section.organization',
fallback: '소속 및 구분 정보입니다.',
@@ -1067,7 +1086,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
_buildEditableTile(
field: 'department',
label: tr('ui.userfront.profile.field.department', fallback: '소속'),
label: tr(
'ui.userfront.profile.field.department',
fallback: '소속',
),
value: profile.department,
profile: profile,
isUpdating: isUpdating,
@@ -1075,7 +1097,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const Divider(height: 24),
_buildReadOnlyTile(
tr('ui.userfront.profile.field.affiliation', fallback: '구분'),
tr(
'ui.userfront.profile.field.affiliation',
fallback: '구분',
),
profile.affiliationType,
),
if (profile.tenant != null) ...[
@@ -1091,7 +1116,10 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (profile.companyCode.isNotEmpty) ...[
const Divider(height: 24),
_buildReadOnlyTile(
tr('ui.userfront.profile.field.company_code', fallback: '회사코드'),
tr(
'ui.userfront.profile.field.company_code',
fallback: '회사코드',
),
profile.companyCode,
),
],
@@ -1148,7 +1176,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
onPressed: () =>
ref.read(profileProvider.notifier).loadProfile(),
child: Text(tr('ui.common.retry', fallback: '재시도')),
),
],
@@ -1193,11 +1222,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
body: Row(
children: [
if (isWide)
SizedBox(
width: 240,
child: _buildSideMenu(context),
),
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
Expanded(child: _buildContent(profile, isUpdating)),
],
),

View File

@@ -4,11 +4,7 @@ class ProfileInfoRow extends StatelessWidget {
final String label;
final String value;
const ProfileInfoRow({
super.key,
required this.label,
required this.value,
});
const ProfileInfoRow({super.key, required this.label, required this.value});
@override
Widget build(BuildContext context) {

Some files were not shown because too many files have changed in this diff Show More