1
0
forked from baron/baron-sso

Merge branch 'feature/tenant-group-239' into dev

This commit is contained in:
2026-02-12 10:46:05 +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 # Ory Keto Configuration
KETO_VERSION=v25.4.0-distroless KETO_VERSION=v25.4.0-distroless
KETO_READ_URL=http://keto:4466
KETO_WRITE_URL=http://keto:4467
# KETO_READ_PORT=4466 # Internal only # KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # Internal only # KETO_WRITE_PORT=4467 # Internal only

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -18,6 +18,7 @@ import {
approveTenant, approveTenant,
deleteTenant, deleteTenant,
fetchTenant, fetchTenant,
fetchTenantGroups,
updateTenant, updateTenant,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
@@ -35,11 +36,17 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId), queryFn: () => fetchTenant(tenantId),
}); });
const groupsQuery = useQuery({
queryKey: ["tenant-groups", { limit: 100 }],
queryFn: () => fetchTenantGroups(100, 0),
});
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [status, setStatus] = useState("active"); const [status, setStatus] = useState("active");
const [domains, setDomains] = useState(""); const [domains, setDomains] = useState("");
const [tenantGroupId, setTenantGroupId] = useState("");
useEffect(() => { useEffect(() => {
if (tenantQuery.data) { if (tenantQuery.data) {
@@ -48,6 +55,7 @@ export function TenantProfilePage() {
setDescription(tenantQuery.data.description ?? ""); setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status); setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? ""); setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
} }
}, [tenantQuery.data]); }, [tenantQuery.data]);
@@ -58,6 +66,7 @@ export function TenantProfilePage() {
slug, slug,
description: description || undefined, description: description || undefined,
status, status,
tenantGroupId: tenantGroupId || undefined,
domains: domains domains: domains
.split(",") .split(",")
.map((d) => d.trim()) .map((d) => d.trim())
@@ -136,6 +145,25 @@ export function TenantProfilePage() {
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
</div> </div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Tenant Group</Label>
<select
value={tenantGroupId}
onChange={(e) => setTenantGroupId(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value=""> </option>
{groupsQuery.data?.items.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.slug})
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
.
.
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
Allowed Domains (Comma separated) Allowed Domains (Comma separated)

View File

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

View File

@@ -27,6 +27,7 @@ export type TenantSummary = {
status: string; status: string;
domains?: string[]; domains?: string[];
config?: Record<string, unknown>; config?: Record<string, unknown>;
tenantGroupId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -38,6 +39,7 @@ export type TenantCreateRequest = {
status?: string; status?: string;
domains?: string[]; domains?: string[];
config?: Record<string, unknown>; config?: Record<string, unknown>;
tenantGroupId?: string;
}; };
export type TenantListResponse = { export type TenantListResponse = {
@@ -54,6 +56,7 @@ export type TenantUpdateRequest = {
status?: string; status?: string;
domains?: string[]; domains?: string[];
config?: Record<string, unknown>; config?: Record<string, unknown>;
tenantGroupId?: string;
}; };
export type ApiKeySummary = { export type ApiKeySummary = {
@@ -92,6 +95,26 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data; return data;
} }
// Authentication
export type LoginRequest = {
loginId: string;
password?: string;
};
export type LoginResponse = {
sessionToken: string;
refreshToken?: string;
userId?: string;
};
export async function login(payload: LoginRequest) {
const { data } = await apiClient.post<LoginResponse>(
"/v1/auth/password/login",
payload,
);
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) { export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>( const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants", "/v1/admin/tenants",
@@ -139,7 +162,7 @@ export async function approveTenant(tenantId: string) {
return data; return data;
} }
// Group Management // User Group Management (Within a Tenant)
export type GroupMember = { export type GroupMember = {
id: string; id: string;
name: string; name: string;
@@ -191,6 +214,123 @@ export async function removeGroupMember(groupId: string, userId: string) {
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`); await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
} }
// Tenant Group Management (Global Grouping of Tenants)
export type TenantGroupSummary = {
id: string;
name: string;
slug: string;
description: string;
tenants?: TenantSummary[];
config?: Record<string, any>;
createdAt: string;
updatedAt: string;
};
export type TenantGroupListResponse = {
items: TenantGroupSummary[];
total: number;
limit: number;
offset: number;
};
export async function fetchTenantGroups(limit = 50, offset = 0) {
const { data } = await apiClient.get<TenantGroupListResponse>(
"/v1/admin/tenant-groups",
{
params: { limit, offset },
},
);
return data;
}
export async function fetchTenantGroup(id: string) {
const { data } = await apiClient.get<TenantGroupSummary>(
`/v1/admin/tenant-groups/${id}`,
);
return data;
}
export async function createTenantGroup(payload: {
name: string;
slug: string;
description?: string;
}) {
const { data } = await apiClient.post<TenantGroupSummary>(
"/v1/admin/tenant-groups",
payload,
);
return data;
}
export async function updateTenantGroup(
id: string,
payload: { name: string; description?: string },
) {
const { data } = await apiClient.put<TenantGroupSummary>(
`/v1/admin/tenant-groups/${id}`,
payload,
);
return data;
}
export async function deleteTenantGroup(id: string) {
await apiClient.delete(`/v1/admin/tenant-groups/${id}`);
}
export async function addTenantToGroup(groupId: string, tenantId: string) {
await apiClient.post(
`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`,
);
}
export async function removeTenantFromGroup(groupId: string, tenantId: string) {
await apiClient.delete(
`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`,
);
}
export type TenantAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchTenantAdmins(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/admins`,
);
return data;
}
export async function addTenantAdmin(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function removeTenantAdmin(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export type GroupAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchGroupAdmins(groupId: string) {
const { data } = await apiClient.get<GroupAdmin[]>(
`/v1/admin/tenant-groups/${groupId}/admins`,
);
return data;
}
export async function addGroupAdmin(groupId: string, userId: string) {
await apiClient.post(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
}
export async function removeGroupAdmin(groupId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
}
// API Key Management (M2M) // API Key Management (M2M)
export type ApiKeyCreateRequest = { export type ApiKeyCreateRequest = {
name: string; name: string;
@@ -371,3 +511,33 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
export async function deleteRelyingParty(id: string) { export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`); await apiClient.delete(`/v1/admin/relying-parties/${id}`);
} }
export type RPOwner = {
subject: string;
name?: string;
email?: string;
type: string;
};
export async function fetchRPOwners(clientId: string) {
const { data } = await apiClient.get<RPOwner[]>(
`/v1/admin/relying-parties/${clientId}/owners`,
);
return data;
}
export async function addRPOwner(clientId: string, subject: string) {
await apiClient.post(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
export async function removeRPOwner(clientId: string, subject: string) {
await apiClient.delete(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}

View File

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

View File

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

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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_"],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
proxy: { proxy: {

View File

@@ -245,7 +245,9 @@ func main() {
// 2. Initialize Handlers // 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db) tenantRepo := repository.NewTenantRepository(db)
tenantGroupRepo := repository.NewTenantGroupRepository(db)
tenantService := service.NewTenantService(tenantRepo) tenantService := service.NewTenantService(tenantRepo)
tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService)
tenantService.SetKetoService(ketoService) // Keto 주입 tenantService.SetKetoService(ketoService) // Keto 주입
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
// relyingPartyRepo removed as SSOT is now Hydra+Keto // relyingPartyRepo removed as SSOT is now Hydra+Keto
@@ -254,14 +256,16 @@ func main() {
secretRepo := repository.NewClientSecretRepository(db) secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db) consentRepo := repository.NewClientConsentRepository(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
tenantHandler := handler.NewTenantHandler(db, tenantService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
kratosAdminService := service.NewKratosAdminService() kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider() oryAdminProvider := service.NewOryProvider()
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db) apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -556,6 +560,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission)
// Tenant Management (Super Admin Only) // Tenant Management (Super Admin Only)
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants) admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
@@ -564,9 +569,27 @@ func main() {
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant) admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant) admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant) admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
// Tenant Group Management (Super Admin Only)
admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups)
admin.Post("/tenant-groups", requireSuperAdmin, tenantGroupHandler.CreateGroup)
admin.Get("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.GetGroup)
admin.Put("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.UpdateGroup)
admin.Delete("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.DeleteGroup)
admin.Post("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.AddTenantToGroup)
admin.Delete("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.RemoveTenantFromGroup)
admin.Get("/tenant-groups/:id/admins", requireSuperAdmin, tenantGroupHandler.ListAdmins)
admin.Post("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.AddAdmin)
admin.Delete("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.RemoveAdmin)
// Relying Party Management (Global List) // Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll) admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
admin.Get("/relying-parties/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.ListOwners)
admin.Post("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.AddOwner)
admin.Delete("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.RemoveOwner)
// Relying Party Management (Tenant Context) // Relying Party Management (Tenant Context)
admin.Post("/tenants/:tenantId/relying-parties", admin.Post("/tenants/:tenantId/relying-parties",
@@ -607,14 +630,24 @@ func main() {
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey) admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정) // 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
dev := api.Group("/dev") dev := api.Group("/dev", requireAdmin)
dev.Get("/clients", devHandler.ListClients) dev.Get("/clients", devHandler.ListClients)
dev.Post("/clients", devHandler.CreateClient) dev.Post("/clients", devHandler.CreateClient)
dev.Get("/clients/:id", devHandler.GetClient) dev.Get("/clients/:id",
dev.Put("/clients/:id", devHandler.UpdateClient) middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret) devHandler.GetClient)
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus) dev.Put("/clients/:id",
dev.Delete("/clients/:id", devHandler.DeleteClient) middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
devHandler.UpdateClient)
dev.Post("/clients/:id/secret/rotate",
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
devHandler.RotateClientSecret)
dev.Patch("/clients/:id/status",
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
devHandler.UpdateClientStatus)
dev.Delete("/clients/:id",
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
devHandler.DeleteClient)
dev.Get("/consents", devHandler.ListConsents) dev.Get("/consents", devHandler.ListConsents)
dev.Delete("/consents", devHandler.RevokeConsents) dev.Delete("/consents", devHandler.RevokeConsents)

View File

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

View File

@@ -25,6 +25,18 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
if t.ParentID != nil { if t.ParentID != nil {
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID) _ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
} }
if t.TenantGroupID != nil {
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent_group", *t.TenantGroupID)
}
}
// 1.1 Sync Tenant Groups (Group Admins)
var groups []domain.TenantGroup
if err := db.Find(&groups).Error; err == nil {
slog.Info("Syncing tenant groups to Keto", "count", len(groups))
for range groups {
// 그룹 관리자 개념 확정 후 관계 생성 로직 추가 예정
}
} }
// 2. Sync All Users // 2. Sync All Users

View File

@@ -80,6 +80,7 @@ type UserProfileResponse struct {
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"` Tenant *Tenant `json:"tenant,omitempty"`
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {

View File

@@ -19,6 +19,8 @@ const (
type Tenant struct { type Tenant struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"`
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"` Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"` Description string `json:"description"`
@@ -34,6 +36,28 @@ func (t *Tenant) IsActive() bool {
return t.Status == TenantStatusActive return t.Status == TenantStatusActive
} }
// GetMergedConfig merges the group-level config with tenant-level config.
// Tenant config takes precedence.
func (t *Tenant) GetMergedConfig() JSONMap {
merged := make(JSONMap)
// 1. Apply Group Config (Base)
if t.TenantGroup != nil && t.TenantGroup.Config != nil {
for k, v := range t.TenantGroup.Config {
merged[k] = v
}
}
// 2. Apply Tenant Config (Overrides)
if t.Config != nil {
for k, v := range t.Config {
merged[k] = v
}
}
return merged
}
// BeforeCreate hook to generate UUID if not present. // BeforeCreate hook to generate UUID if not present.
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) { func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" { if t.ID == "" {

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 package handler
import ( import (
"baron-sso-backend/internal/service"
"runtime" "runtime"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
type AdminHandler struct{} type AdminHandler struct {
Keto service.KetoService
}
func NewAdminHandler() *AdminHandler { func NewAdminHandler(keto service.KetoService) *AdminHandler {
return &AdminHandler{} return &AdminHandler{Keto: keto}
} }
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
} }
func (h *AdminHandler) CheckPermission(c *fiber.Ctx) error {
namespace := c.Query("namespace")
object := c.Query("object")
relation := c.Query("relation")
subject := c.Query("subject")
if namespace == "" || object == "" || relation == "" || subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "namespace, object, relation, and subject are required"})
}
allowed, err := h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"allowed": allowed,
"query": fiber.Map{
"namespace": namespace,
"object": object,
"relation": relation,
"subject": subject,
},
})
}
// GetSystemStats returns runtime statistics for monitoring // GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats var m runtime.MemStats

View File

@@ -125,10 +125,11 @@ func GenerateSecureAlnumToken(length int) string {
func GenerateUserCode() string { func GenerateUserCode() string {
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
return fmt.Sprintf("%c%c-%03d", // [Fixed] 요청하신 포맷 (영문 2자리 + 숫자 6자리, 하이픈 없음)으로 변경
return fmt.Sprintf("%c%c%06d",
letters[rand.Intn(len(letters))], letters[rand.Intn(len(letters))],
letters[rand.Intn(len(letters))], letters[rand.Intn(len(letters))],
rand.Intn(1000), rand.Intn(1000000),
) )
} }
@@ -958,13 +959,20 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
} }
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
userCode := GenerateUserCode() userCode := GenerateUserCode()
token := GenerateSecureToken(3) token := GenerateSecureToken(3)
pendingRef := GenerateSecureToken(3) pendingRef := GenerateSecureToken(3)
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef) slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
// [Added] 사용자가 입력할 간편 코드를 Redis에 저장합니다. (이게 없으면 인증이 안 됩니다)
shortCodePayload, _ := json.Marshal(shortLoginCodePayload{
LoginID: lookupLoginID,
Code: token,
PendingRef: pendingRef,
})
h.RedisService.Set(prefixLoginCodeShort+userCode, string(shortCodePayload), defaultExpiration)
// Store in Redis // Store in Redis
sessionData, _ := json.Marshal(map[string]string{ sessionData, _ := json.Marshal(map[string]string{
"status": statusPending, "status": statusPending,
@@ -1018,12 +1026,13 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
} }
} else { } else {
// Send SMS // Send SMS
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode) phone := sanitizePhoneForSms(loginID)
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 간편 코드: %s", link, userCode)
if drySend { if drySend {
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content) slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", phone, "content", content)
} else { } else {
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) slog.Info("[Enchanted] Sending SMS via Naver Cloud", "to", phone)
if err := h.SmsService.SendSms(loginID, content); err != nil { if err := h.SmsService.SendSms(phone, content); err != nil {
slog.Error("[Enchanted] SMS Failed", "error", err) slog.Error("[Enchanted] SMS Failed", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
} }
@@ -1585,12 +1594,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
// --- OIDC 로그인 흐름 처리 끝 --- // --- OIDC 로그인 흐름 처리 끝 ---
resp := fiber.Map{ resp := fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT, "sessionToken": authInfo.SessionToken.JWT,
"status": "ok", "status": "ok",
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
} }
if authInfo.RefreshToken != nil { if authInfo.RefreshToken != nil {
resp["refreshJwt"] = authInfo.RefreshToken.JWT resp["refreshToken"] = authInfo.RefreshToken.JWT
} }
if authInfo.Subject != "" { if authInfo.Subject != "" {
resp["subject"] = authInfo.Subject resp["subject"] = authInfo.Subject
@@ -2066,6 +2075,16 @@ type kratosCourierRequest struct {
Body string `json:"body"` Body string `json:"body"`
} }
// sanitizePhoneForSms - 네이버 SMS 등 국내 발송기를 위해 +82 형식을 010 형식으로 변환합니다.
func sanitizePhoneForSms(phone string) string {
p := strings.ReplaceAll(phone, "-", "")
p = strings.ReplaceAll(p, " ", "")
if strings.HasPrefix(p, "+82") {
return "0" + p[3:]
}
return p
}
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다. // HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
var req kratosCourierRequest var req kratosCourierRequest
@@ -2444,16 +2463,6 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
return "" return ""
} }
func sanitizePhoneForSms(phone string) string {
sanitized := strings.TrimSpace(phone)
if strings.HasPrefix(sanitized, "+82") {
sanitized = "0" + sanitized[3:]
}
sanitized = strings.ReplaceAll(sanitized, "-", "")
sanitized = strings.ReplaceAll(sanitized, " ", "")
return sanitized
}
// --- User Profile Handlers --- // --- User Profile Handlers ---
func (h *AuthHandler) formatPhoneForDisplay(phone string) string { func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
@@ -3944,6 +3953,13 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
} }
} }
// Fetch Manageable Tenants for Admins
if profile.Role == domain.RoleSuperAdmin || profile.Role == domain.RoleTenantAdmin || profile.Role == domain.RoleRPAdmin {
if tenants, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID); err == nil {
profile.ManageableTenants = tenants
}
}
// 4. Save to Redis Cache (Short TTL) // 4. Save to Redis Cache (Short TTL)
if h.RedisService != nil && cacheKey != "" { if h.RedisService != nil && cacheKey != "" {
if data, err := json.Marshal(profile); err == nil { if data, err := json.Marshal(profile); err == nil {
@@ -4773,10 +4789,7 @@ func extractLoginIDFromClaims(claims map[string]any) string {
} }
func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
@@ -4807,10 +4820,7 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
} }
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") kratosURL := strings.TrimRight(utils.GetEnv("KRATOS_PUBLIC_URL", "http://kratos:4433"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -4833,6 +4843,7 @@ func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err return "", err
} }
return result.ID, nil return result.ID, nil
} }
@@ -4841,10 +4852,7 @@ func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string)
return "", fmt.Errorf("kratos identity id is empty") return "", fmt.Errorf("kratos identity id is empty")
} }
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") kratosAdminURL := strings.TrimRight(utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"), "/")
if kratosAdminURL == "" {
kratosAdminURL = "http://kratos:4434"
}
payload := map[string]interface{}{ payload := map[string]interface{}{
"identity_id": identityID, "identity_id": identityID,

View File

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

View File

@@ -22,15 +22,17 @@ type DevHandler struct {
SecretRepo domain.ClientSecretRepository SecretRepo domain.ClientSecretRepository
KratosAdmin *service.KratosAdminService KratosAdmin *service.KratosAdminService
ConsentRepo repository.ClientConsentRepository ConsentRepo repository.ClientConsentRepository
RPService service.RelyingPartyService
} }
func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler { func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpService service.RelyingPartyService) *DevHandler {
return &DevHandler{ return &DevHandler{
Hydra: service.NewHydraAdminService(), Hydra: service.NewHydraAdminService(),
Redis: redis, Redis: redis,
SecretRepo: secretRepo, SecretRepo: secretRepo,
KratosAdmin: service.NewKratosAdminService(), KratosAdmin: service.NewKratosAdminService(),
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
RPService: rpService,
} }
} }
@@ -95,38 +97,58 @@ type clientUpsertRequest struct {
} }
func (h *DevHandler) ListClients(c *fiber.Ctx) error { func (h *DevHandler) ListClients(c *fiber.Ctx) error {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found"})
}
// 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) limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0) offset := c.QueryInt("offset", 0)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset) clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil { if err != nil {
if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "clients not found"})
} }
errMsg := err.Error()
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "Hydra service is unavailable. Please check if Ory Hydra is running.",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errMsg})
}
items := make([]clientSummary, 0, len(clients)) items := make([]clientSummary, 0, len(clients))
for _, client := range clients { for _, client := range clients {
items = append(items, h.mapClientSummary(client)) 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 {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
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{ return c.JSON(clientListResponse{
Items: items, Items: items,
Limit: limit, Limit: len(items),
Offset: offset, Offset: 0,
}) })
} }
@@ -144,6 +166,11 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// Set for audit logging
if tid, ok := client.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
summary := h.mapClientSummary(*client) summary := h.mapClientSummary(*client)
return c.JSON(clientDetailResponse{ return c.JSON(clientDetailResponse{
Client: summary, Client: summary,
@@ -197,11 +224,49 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
} }
func (h *DevHandler) CreateClient(c *fiber.Ctx) error { func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
}
var req clientUpsertRequest var req clientUpsertRequest
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
} }
// Determine Tenant ID
targetTenantID := c.Get("X-Tenant-ID")
if targetTenantID == "" && profile.TenantID != nil {
targetTenantID = *profile.TenantID
}
if targetTenantID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"})
}
// Set for audit logging
c.Locals("tenant_id", targetTenantID)
// Validate Permission
isAllowed := false
if profile.Role == domain.RoleSuperAdmin {
isAllowed = true
} else {
for _, t := range profile.ManageableTenants {
if t.ID == targetTenantID {
isAllowed = true
break
}
}
if !isAllowed && profile.TenantID != nil && *profile.TenantID == targetTenantID {
isAllowed = true
}
}
if !isAllowed {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "you do not have permission to create clients for this tenant"})
}
clientID := strings.TrimSpace(valueOr(req.ID, "")) clientID := strings.TrimSpace(valueOr(req.ID, ""))
if clientID == "" { if clientID == "" {
clientID = uuid.NewString() clientID = uuid.NewString()
@@ -257,11 +322,18 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
Metadata: metadata, Metadata: metadata,
} }
created, err := h.Hydra.CreateClient(c.Context(), clientReq) // Use RPService to ensure Keto relations are created
rp, err := h.RPService.Create(c.Context(), targetTenantID, clientReq)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// Fetch back the Hydra client to get the secret (RPService.Create returns domain.RelyingParty which has limited fields)
created, err := h.Hydra.GetClient(c.Context(), rp.ClientID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "client created but failed to retrieve details"})
}
// Store secret in metadata for later retrieval // Store secret in metadata for later retrieval
if created.ClientSecret != "" { if created.ClientSecret != "" {
// 1. Store in PostgreSQL (Source of Truth) // 1. Store in PostgreSQL (Source of Truth)
@@ -307,6 +379,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
clientType := "" clientType := ""
if req.Type != nil { if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type)) clientType = strings.ToLower(strings.TrimSpace(*req.Type))
@@ -382,6 +459,14 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
} }
// Fetch first for audit log tenant_id
client, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
if tid, ok := client.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
}
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
if errors.Is(err, service.ErrHydraNotFound) { if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -403,11 +488,24 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
} }
func (h *DevHandler) ListConsents(c *fiber.Ctx) error { func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
}
clientID := strings.TrimSpace(c.Query("client_id")) clientID := strings.TrimSpace(c.Query("client_id"))
if clientID == "" { if clientID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required"})
} }
// Permission Check
if profile.Role != domain.RoleSuperAdmin {
allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "view")
if err != nil || !allowed {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to view consents for this client"})
}
}
subject := strings.TrimSpace(c.Query("subject")) subject := strings.TrimSpace(c.Query("subject"))
limit := c.QueryInt("limit", 50) limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0) offset := c.QueryInt("offset", 0)
@@ -484,12 +582,28 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
} }
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
}
subject := strings.TrimSpace(c.Query("subject")) subject := strings.TrimSpace(c.Query("subject"))
if subject == "" { if subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subject is required"})
} }
clientID := strings.TrimSpace(c.Query("client_id")) clientID := strings.TrimSpace(c.Query("client_id"))
// Permission Check (if clientID is provided)
if clientID != "" && profile.Role != domain.RoleSuperAdmin {
allowed, err := h.RPService.CheckPermission(c.Context(), profile.ID, clientID, "manage")
if err != nil || !allowed {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: you do not have permission to revoke consents for this client"})
}
} else if clientID == "" && profile.Role != domain.RoleSuperAdmin {
// If clientID is not provided, we might need a more global check or just disallow it for non-superadmins
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client_id is required for non-superadmins"})
}
// If subject is not a UUID, try to resolve it as an identifier (email/username) // If subject is not a UUID, try to resolve it as an identifier (email/username)
if _, err := uuid.Parse(subject); err != nil { if _, err := uuid.Parse(subject); err != nil {
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject) resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject)
@@ -532,6 +646,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
// 3. Update Hydra // 3. Update Hydra
current.ClientSecret = newSecret current.ClientSecret = newSecret
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current) updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)

View File

@@ -1,8 +1,10 @@
package handler package handler
import ( import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -10,8 +12,75 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
) )
type MockRPService struct {
mock.Mock
}
func (m *MockRPService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) {
args := m.Called(ctx, tenantID, client)
return args.Get(0).(*domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) {
args := m.Called(ctx, clientID)
return args.Get(0).(*domain.RelyingParty), args.Get(1).(*domain.HydraClient), args.Error(2)
}
func (m *MockRPService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) {
args := m.Called(ctx)
return args.Get(0).([]domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) {
args := m.Called(ctx, tenantIDs)
return args.Get(0).([]domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) {
args := m.Called(ctx, clientID, client)
return args.Get(0).(*domain.RelyingParty), args.Error(1)
}
func (m *MockRPService) Delete(ctx context.Context, clientID string) error {
args := m.Called(ctx, clientID)
return args.Error(0)
}
func (m *MockRPService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) {
args := m.Called(ctx, userID, clientID, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockRPService) AddOwner(ctx context.Context, clientID, subject string) error {
args := m.Called(ctx, clientID, subject)
return args.Error(0)
}
func (m *MockRPService) RemoveOwner(ctx context.Context, clientID, subject string) error {
args := m.Called(ctx, clientID, subject)
return args.Error(0)
}
func (m *MockRPService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
args := m.Called(ctx, clientID)
return args.Get(0).([]string), args.Error(1)
}
func withMockProfile(profile *domain.UserProfileResponse) fiber.Handler {
return func(c *fiber.Ctx) error {
c.Locals("user_profile", profile)
return c.Next()
}
}
func TestListClients_Success(t *testing.T) { func TestListClients_Success(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" { if r.URL.Path == "/clients" {
@@ -30,7 +99,11 @@ func TestListClients_Success(t *testing.T) {
}, },
} }
app := fiber.New() app := fiber.New()
app.Get("/api/v1/dev/clients", h.ListClients) adminProfile := &domain.UserProfileResponse{
ID: "admin-1",
Role: domain.RoleSuperAdmin,
}
app.Get("/api/v1/dev/clients", withMockProfile(adminProfile), h.ListClients)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil)
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -66,7 +139,11 @@ func TestGetClient_Success(t *testing.T) {
}, },
} }
app := fiber.New() app := fiber.New()
app.Get("/api/v1/dev/clients/:id", h.GetClient) adminProfile := &domain.UserProfileResponse{
ID: "admin-1",
Role: domain.RoleSuperAdmin,
}
app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil)
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -92,7 +169,11 @@ func TestGetClient_NotFound(t *testing.T) {
}, },
} }
app := fiber.New() app := fiber.New()
app.Get("/api/v1/dev/clients/:id", h.GetClient) adminProfile := &domain.UserProfileResponse{
ID: "admin-1",
Role: domain.RoleSuperAdmin,
}
app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil)
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
@@ -109,30 +190,49 @@ func TestCreateClient_Success(t *testing.T) {
"client_secret": "secret-123", "client_secret": "secret-123",
}), nil }), nil
} }
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil if r.Method == http.MethodGet && r.URL.Path == "/clients/new-client-123" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "new-client-123",
"client_name": "New App",
"client_secret": "secret-123",
"metadata": map[string]interface{}{"status": "active"},
}), nil
}
return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error path: " + r.URL.Path}), nil
}) })
secretRepo := &mockSecretRepo{secrets: make(map[string]string)} secretRepo := &mockSecretRepo{secrets: make(map[string]string)}
redisRepo := &mockRedisRepo{data: make(map[string]string)} redisRepo := &mockRedisRepo{data: make(map[string]string)}
mockRP := new(MockRPService)
h := &DevHandler{ h := &DevHandler{
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
PublicURL: "http://hydra-public.test",
HTTPClient: &http.Client{Transport: transport}, HTTPClient: &http.Client{Transport: transport},
}, },
SecretRepo: secretRepo, SecretRepo: secretRepo,
Redis: redisRepo, Redis: redisRepo,
RPService: mockRP,
} }
app := fiber.New() app := fiber.New()
app.Post("/api/v1/dev/clients", h.CreateClient) adminProfile := &domain.UserProfileResponse{
ID: "admin-1",
Role: domain.RoleSuperAdmin,
}
app.Post("/api/v1/dev/clients", withMockProfile(adminProfile), h.CreateClient)
body, _ := json.Marshal(map[string]interface{}{ body, _ := json.Marshal(map[string]interface{}{
"client_name": "New App", "client_name": "New App",
"type": "confidential", "type": "confidential",
"redirectUris": []string{"http://localhost/cb"}, "redirectUris": []string{"http://localhost/cb"},
}) })
mockRP.On("Create", mock.Anything, "t1", mock.Anything).Return(&domain.RelyingParty{ClientID: "new-client-123"}, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tenant-ID", "t1")
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)

View File

@@ -10,10 +10,11 @@ import (
type RelyingPartyHandler struct { type RelyingPartyHandler struct {
Service service.RelyingPartyService Service service.RelyingPartyService
UserSvc *service.KratosAdminService
} }
func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler { func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s} return &RelyingPartyHandler{Service: s, UserSvc: userSvc}
} }
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error { func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
@@ -110,3 +111,58 @@ func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)
} }
func (h *RelyingPartyHandler) ListOwners(c *fiber.Ctx) error {
clientID := c.Params("id")
subjects, err := h.Service.ListOwners(c.Context(), clientID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type ownerInfo struct {
Subject string `json:"subject"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Type string `json:"type"` // "user" or "group"
}
owners := make([]ownerInfo, 0, len(subjects))
for _, s := range subjects {
info := ownerInfo{Subject: s, Type: "unknown"}
if len(s) > 5 && s[:5] == "User:" {
info.Type = "user"
userID := s[5:]
identity, err := h.UserSvc.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
info.Name, _ = identity.Traits["name"].(string)
info.Email, _ = identity.Traits["email"].(string)
}
} else if len(s) > 10 && s[:10] == "UserGroup:" {
info.Type = "group"
// Group name enrichment could be added if we have a GroupService here
}
owners = append(owners, info)
}
return c.JSON(owners)
}
func (h *RelyingPartyHandler) AddOwner(c *fiber.Ctx) error {
clientID := c.Params("id")
subject := c.Params("subject") // e.g. "User:uuid"
if err := h.Service.AddOwner(c.Context(), clientID, subject); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "owner added"})
}
func (h *RelyingPartyHandler) RemoveOwner(c *fiber.Ctx) error {
clientID := c.Params("id")
subject := c.Params("subject")
if err := h.Service.RemoveOwner(c.Context(), clientID, subject); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "owner removed"})
}

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,10 +14,12 @@ import (
type TenantHandler struct { type TenantHandler struct {
DB *gorm.DB DB *gorm.DB
Service service.TenantService Service service.TenantService
Keto service.KetoService
UserSvc *service.KratosAdminService
} }
func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler { func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc} return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc}
} }
type tenantSummary struct { type tenantSummary struct {
@@ -26,6 +28,7 @@ type tenantSummary struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
TenantGroupID *string `json:"tenantGroupId,omitempty"`
Domains []string `json:"domains,omitempty"` Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"` Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
@@ -100,7 +103,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
} }
var tenants []domain.Tenant var tenants []domain.Tenant
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
@@ -123,7 +126,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
} }
var tenant domain.Tenant var tenant domain.Tenant
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil { if err := h.DB.Preload("Domains").Preload("TenantGroup").First(&tenant, "id = ?", tenantID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
} }
@@ -208,6 +211,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
Slug *string `json:"slug"` Slug *string `json:"slug"`
Description *string `json:"description"` Description *string `json:"description"`
Status *string `json:"status"` Status *string `json:"status"`
TenantGroupID *string `json:"tenantGroupId"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
Config map[string]any `json:"config"` Config map[string]any `json:"config"`
} }
@@ -251,6 +255,29 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
tenant.Config = req.Config tenant.Config = req.Config
} }
// Handle Group Change
if req.TenantGroupID != nil {
oldGroupID := tenant.TenantGroupID
newGroupID := req.TenantGroupID
if *newGroupID == "" {
newGroupID = nil
}
// Update Keto if group changed
if h.Keto != nil {
// Remove old group relation if existed
if oldGroupID != nil && (newGroupID == nil || *oldGroupID != *newGroupID) {
_ = h.Keto.DeleteRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *oldGroupID)
}
// Add new group relation
if newGroupID != nil && (oldGroupID == nil || *oldGroupID != *newGroupID) {
_ = h.Keto.CreateRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *newGroupID)
}
}
tenant.TenantGroupID = newGroupID
}
if err := h.DB.Save(&tenant).Error; err != nil { if err := h.DB.Save(&tenant).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
@@ -301,6 +328,58 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)
} }
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
tenantID := c.Params("id")
userIDs, err := h.Service.ListTenantAdmins(c.Context(), tenantID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type adminInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
admins := make([]adminInfo, 0, len(userIDs))
for _, uid := range userIDs {
identity, err := h.UserSvc.GetIdentity(c.Context(), uid)
if err == nil && identity != nil {
name, _ := identity.Traits["name"].(string)
email, _ := identity.Traits["email"].(string)
admins = append(admins, adminInfo{
ID: uid,
Name: name,
Email: email,
})
} else {
admins = append(admins, adminInfo{ID: uid})
}
}
return c.JSON(admins)
}
func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.AddTenantAdmin(c.Context(), tenantID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin added to tenant"})
}
func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.RemoveTenantAdmin(c.Context(), tenantID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin removed from tenant"})
}
func mapTenantSummary(t domain.Tenant) tenantSummary { func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains)) domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains { for _, d := range t.Domains {
@@ -313,8 +392,9 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
Slug: t.Slug, Slug: t.Slug,
Description: t.Description, Description: t.Description,
Status: t.Status, Status: t.Status,
TenantGroupID: t.TenantGroupID,
Domains: domains, Domains: domains,
Config: t.Config, Config: t.GetMergedConfig(),
CreatedAt: t.CreatedAt.Format(time.RFC3339), CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.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) m.Called(keto)
} }
func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID)
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
args := m.Called(ctx, tenantID, userID)
return args.Error(0)
}
func (m *MockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
args := m.Called(ctx, tenantID, userID)
return args.Error(0)
}
func (m *MockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]string), args.Error(1)
}
func TestTenantHandler_CreateTenant(t *testing.T) { func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)

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"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
} }
// Set tenant_id for audit logging if namespace is Tenant
if namespace == "Tenant" {
c.Locals("tenant_id", objectID)
}
// Check with Keto // Check with Keto
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
if err != nil || !allowed { if err != nil || !allowed {

View File

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

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) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
FindByName(ctx context.Context, name string) (*domain.Tenant, error) FindByName(ctx context.Context, name string) (*domain.Tenant, error)
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
AddDomain(ctx context.Context, tenantID string, domainName string) error AddDomain(ctx context.Context, tenantID string, domainName string) error
} }
@@ -41,6 +42,17 @@ func (r *tenantRepository) FindByID(ctx context.Context, id string) (*domain.Ten
return &tenant, nil return &tenant, nil
} }
func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
var tenants []domain.Tenant
if len(ids) == 0 {
return tenants, nil
}
if err := r.db.WithContext(ctx).Preload("Domains").Where("id IN ?", ids).Find(&tenants).Error; err != nil {
return nil, err
}
return tenants, nil
}
func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { func (r *tenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
var tenant domain.Tenant var tenant domain.Tenant
if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil { if err := r.db.WithContext(ctx).Preload("Domains").Where("slug = ?", slug).First(&tenant).Error; err != nil {

View File

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

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"baron-sso-backend/internal/utils"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@@ -9,7 +10,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"os"
"time" "time"
) )
@@ -18,6 +18,7 @@ type KetoService interface {
CreateRelation(ctx context.Context, namespace, object, relation, subject string) error CreateRelation(ctx context.Context, namespace, object, relation, subject string) error
DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error
ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error)
ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error)
} }
type ketoService struct { type ketoService struct {
@@ -27,14 +28,8 @@ type ketoService struct {
} }
func NewKetoService() KetoService { func NewKetoService() KetoService {
readURL := os.Getenv("KETO_READ_URL") readURL := utils.GetEnv("KETO_READ_URL", "http://keto:4466")
if readURL == "" { writeURL := utils.GetEnv("KETO_WRITE_URL", "http://keto:4467")
readURL = "http://keto:4466"
}
writeURL := os.Getenv("KETO_WRITE_URL")
if writeURL == "" {
writeURL = "http://keto:4467"
}
return &ketoService{ return &ketoService{
readURL: readURL, readURL: readURL,
@@ -192,3 +187,40 @@ func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, rel
slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject) slog.Info("Keto relation deleted", "namespace", namespace, "object", object, "relation", relation, "subject", subject)
return nil return nil
} }
func (s *ketoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.readURL))
q := u.Query()
q.Set("namespace", namespace)
q.Set("relation", relation)
q.Set("subject_id", subject)
u.RawQuery = q.Encode()
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("keto returned status %d: %s", resp.StatusCode, string(body))
}
var res relationTuplesResponse
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return nil, err
}
objects := make([]string, 0, len(res.RelationTuples))
seen := make(map[string]bool)
for _, rt := range res.RelationTuples {
if !seen[rt.Object] {
objects = append(objects, rt.Object)
seen[rt.Object] = true
}
}
return objects, nil
}

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"baron-sso-backend/internal/utils"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@@ -28,7 +29,7 @@ type KratosAdminService struct {
func NewKratosAdminService() *KratosAdminService { func NewKratosAdminService() *KratosAdminService {
return &KratosAdminService{ return &KratosAdminService{
AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"), AdminURL: utils.GetEnv("KRATOS_ADMIN_URL", "http://kratos:4434"),
} }
} }
@@ -227,8 +228,9 @@ func (s *KratosAdminService) httpClient() *http.Client {
} }
func getenvKratos(key, fallback string) string { func getenvKratos(key, fallback string) string {
if v := os.Getenv(key); v != "" { v := os.Getenv(key)
return v if v == "" {
}
return fallback return fallback
} }
return strings.Trim(v, "\"")
}

View File

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

View File

@@ -15,6 +15,10 @@ type RelyingPartyService interface {
ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error)
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
Delete(ctx context.Context, clientID string) error Delete(ctx context.Context, clientID string) error
CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error)
AddOwner(ctx context.Context, clientID, subject string) error
RemoveOwner(ctx context.Context, clientID, subject string) error
ListOwners(ctx context.Context, clientID string) ([]string, error)
} }
type relyingPartyService struct { type relyingPartyService struct {
@@ -158,6 +162,31 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
return nil return nil
} }
func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) {
return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation)
}
func (s *relyingPartyService) AddOwner(ctx context.Context, clientID, subject string) error {
return s.ketoService.CreateRelation(ctx, "RelyingParty", clientID, "owners", subject)
}
func (s *relyingPartyService) RemoveOwner(ctx context.Context, clientID, subject string) error {
return s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "owners", subject)
}
func (s *relyingPartyService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", clientID, "owners", "")
if err != nil {
return nil, err
}
subjects := make([]string, 0, len(tuples))
for _, t := range tuples {
subjects = append(subjects, t.SubjectID)
}
return subjects, nil
}
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty { func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
if client == nil { if client == nil {
return nil return nil

View File

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

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) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
GetTenant(ctx context.Context, id string) (*domain.Tenant, error) GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
ApproveTenant(ctx context.Context, id string) error ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가 SetKetoService(keto KetoService) // 추가
AddTenantAdmin(ctx context.Context, tenantID, userID string) error
RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error
ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error)
} }
type tenantService struct { type tenantService struct {
@@ -39,6 +43,60 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
return s.repo.FindByID(ctx, id) return s.repo.FindByID(ctx, id)
} }
func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
if s.keto == nil {
return nil, errors.New("keto service not initialized")
}
// 1. Get directly managed tenants
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID)
if err != nil {
slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err)
}
// 2. Get managed tenant groups
groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID)
if err != nil {
slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err)
}
// 3. Get tenants belonging to those groups
var groupInheritedTenantIDs []string
for _, groupID := range groupIDs {
// In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_
// To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_
// Wait, my ListObjects lists objects given a subject.
// So subject="TenantGroup:"+groupID+"#_"
// Object is Tenant ID.
ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID)
if err == nil {
for _, t := range ts {
groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object)
}
}
}
// Combine and deduplicate IDs
allIDsMap := make(map[string]bool)
for _, id := range directTenantIDs {
allIDsMap[id] = true
}
for _, id := range groupInheritedTenantIDs {
allIDsMap[id] = true
}
allIDs := make([]string, 0, len(allIDsMap))
for id := range allIDsMap {
allIDs = append(allIDs, id)
}
if len(allIDs) == 0 {
return []domain.Tenant{}, nil
}
return s.repo.FindByIDs(ctx, allIDs)
}
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
// Validate Slug // Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok { if ok, msg := utils.ValidateSlug(slug); !ok {
@@ -153,3 +211,35 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return s.repo.FindBySlug(ctx, slug) return s.repo.FindBySlug(ctx, slug)
} }
func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
if s.keto == nil {
return errors.New("keto service not initialized")
}
return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
}
func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
if s.keto == nil {
return errors.New("keto service not initialized")
}
return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
}
func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
if s.keto == nil {
return nil, errors.New("keto service not initialized")
}
tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "")
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(tuples))
for _, t := range tuples {
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
userIDs = append(userIDs, t.SubjectID[5:])
}
}
return userIDs, nil
}

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 export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {

View File

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

View File

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

View File

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

View File

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

View File

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

1316
devfront/src/locales/en.toml Normal file

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ missing = "Missing"
[msg] [msg]
[msg.admin] [msg.admin]
logout_confirm = "Are you sure you want to log out?"
idp_env_prod = "IDP env: prod" idp_env_prod = "IDP env: prod"
scope_admin = "Scoped to /admin" scope_admin = "Scoped to /admin"
session_ttl = "Session TTL: 15m admin" session_ttl = "Session TTL: 15m admin"
@@ -841,9 +842,6 @@ role = "ROLE"
status = "STATUS" status = "STATUS"
tenant_dept = "TENANT / DEPT" tenant_dept = "TENANT / DEPT"
[ui.btn]
cancel = "Cancel"
save = "Save"
[ui.common] [ui.common]
add = "Add" add = "Add"
@@ -1091,8 +1089,6 @@ title = "Stack readiness"
plane = "Dev Plane" plane = "Dev Plane"
subtitle = "Manage your applications" subtitle = "Manage your applications"
[ui.nav]
dashboard = "Dashboard"
[ui.userfront] [ui.userfront]
app_title = "App Title" app_title = "App Title"
@@ -1306,3 +1302,15 @@ verify = "Verify"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "Action" action = "Action"
[ui.admin.nav]
api_keys = "API Keys"
audit_logs = "Audit Logs"
auth_guard = "Auth Guard"
logout = "Logout"
overview = "Overview"
relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard"
tenant_groups = "Tenant Groups"
tenants = "Tenants"
users = "Users"

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ COPY . .
# Get dependencies and build for web # Get dependencies and build for web
RUN flutter pub get RUN flutter pub get
RUN touch .env RUN touch .env
RUN flutter build web --release --no-tree-shake-icons --wasm RUN flutter build web --release --no-tree-shake-icons
# Stage 2: Serve with Nginx # Stage 2: Serve with Nginx
FROM nginx:alpine FROM nginx:alpine

View File

@@ -11,7 +11,8 @@ class AuditService {
return dotenv.env[key] ?? fallback; return dotenv.env[key] ?? fallback;
} }
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); static String get _baseUrl =>
_envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
static Future<void> logEvent({ static Future<void> logEvent({
required String userId, required String userId,

View File

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

View File

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

View File

@@ -25,7 +25,9 @@ class LoggerService {
); );
// 2. Configure Standard Logger (logging package) // 2. Configure Standard Logger (logging package)
std_log.Logger.root.level = kReleaseMode ? std_log.Level.WARNING : std_log.Level.ALL; std_log.Logger.root.level = kReleaseMode
? std_log.Level.WARNING
: std_log.Level.ALL;
std_log.Logger.root.onRecord.listen((record) { std_log.Logger.root.onRecord.listen((record) {
if (kReleaseMode) { if (kReleaseMode) {
@@ -47,7 +49,11 @@ class LoggerService {
void _logPretty(std_log.LogRecord record) { void _logPretty(std_log.LogRecord record) {
if (record.level >= std_log.Level.SEVERE) { if (record.level >= std_log.Level.SEVERE) {
_prettyLogger.e(record.message, error: record.error, stackTrace: record.stackTrace); _prettyLogger.e(
record.message,
error: record.error,
stackTrace: record.stackTrace,
);
} else if (record.level >= std_log.Level.WARNING) { } else if (record.level >= std_log.Level.WARNING) {
_prettyLogger.w(record.message); _prettyLogger.w(record.message);
} else if (record.level >= std_log.Level.INFO) { } else if (record.level >= std_log.Level.INFO) {

View File

@@ -3,9 +3,64 @@
import 'dart:async'; import 'dart:async';
import 'dart:html' as html; import 'dart:html' as html;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'auth_token_store.dart';
void implSendLoginSuccess(String token) { void implSendLoginSuccess(String token) {
final message = {'type': 'LOGIN_SUCCESS', 'token': token}; var effectiveToken = token;
if (effectiveToken.isEmpty) {
effectiveToken = AuthTokenStore.getToken() ?? "";
}
final fullUrl = html.window.location.href;
final uri = Uri.base;
// Try to find redirect_uri from standard parsing first, then manual string search
String? redirectUri =
uri.queryParameters['redirect_uri'] ??
uri.queryParameters['redirect_url'];
if (redirectUri == null) {
// Manual fallback for cases where Uri.base misses params
final searchParams = html.window.location.search;
if (searchParams != null && searchParams.isNotEmpty) {
final sUri = Uri.parse(
'?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}',
);
redirectUri =
sUri.queryParameters['redirect_uri'] ??
sUri.queryParameters['redirect_url'];
}
}
// Final fallback: regex or manual search in fullUrl
if (redirectUri == null) {
for (final key in ['redirect_uri=', 'redirect_url=']) {
if (fullUrl.contains(key)) {
final start = fullUrl.indexOf(key) + key.length;
var end = fullUrl.indexOf('&', start);
if (end == -1) end = fullUrl.length;
final raw = fullUrl.substring(start, end);
try {
redirectUri = Uri.decodeComponent(raw);
break;
} catch (_) {}
}
}
}
if (redirectUri != null && redirectUri.isNotEmpty) {
// Redirection flow
final target = Uri.parse(redirectUri);
final query = Map<String, String>.from(target.queryParameters);
query['token'] = effectiveToken;
final finalUri = target.replace(queryParameters: query);
debugPrint('Redirecting to: ${finalUri.toString()}');
html.window.location.href = finalUri.toString();
return;
}
final message = {'type': 'LOGIN_SUCCESS', 'token': effectiveToken};
if (html.window.opener != null) { if (html.window.opener != null) {
try { try {
@@ -26,5 +81,13 @@ void implSendLoginSuccess(String token) {
} }
bool implIsPopup() { bool implIsPopup() {
return html.window.opener != null; if (html.window.opener != null) return true;
// Fallback: Check query parameters for integration source
final uri = Uri.base;
if (uri.queryParameters['source'] == 'adminfront') return true;
// Manual parse fallback for cases where Uri.base might miss params due to hash routing
final search = html.window.location.search;
return search != null && search.contains('source=adminfront');
} }

View File

@@ -86,7 +86,10 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} else { } else {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red), const SnackBar(
content: Text('Invalid Password. Access Denied.'),
backgroundColor: Colors.red,
),
); );
context.go('/'); // Kick out context.go('/'); // Kick out
} }
@@ -116,7 +119,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
} }
} }
String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(); String? phone = _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim();
if (phone != null && !phone.contains('@')) { if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), ''); phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) { if (phone.startsWith('010')) {
@@ -128,14 +133,21 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
await AuthProxyService.createUser( await AuthProxyService.createUser(
loginId: loginId, loginId: loginId,
adminPassword: _verifiedAdminPassword!, adminPassword: _verifiedAdminPassword!,
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), email: _emailController.text.trim().isEmpty
? null
: _emailController.text.trim(),
phone: phone, phone: phone,
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(), displayName: _nameController.text.trim().isEmpty
? null
: _nameController.text.trim(),
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green), const SnackBar(
content: Text('User created successfully!'),
backgroundColor: Colors.green,
),
); );
_formKey.currentState!.reset(); _formKey.currentState!.reset();
_loginIdController.clear(); _loginIdController.clear();
@@ -158,9 +170,7 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Hide content until authorized // Hide content until authorized
if (!_isAuthorized) { if (!_isAuthorized) {
return const Scaffold( return const Scaffold(body: Center(child: CircularProgressIndicator()));
body: Center(child: CircularProgressIndicator()),
);
} }
return Scaffold( return Scaffold(
@@ -194,7 +204,9 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)", helperText: "Unique identifier (Email or Phone)",
), ),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null, validator: (value) => value == null || value.isEmpty
? 'Please enter Login ID'
: null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

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

View File

@@ -140,7 +140,10 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
child: Text( child: Text(
_message!, _message!,
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold), style: TextStyle(
color: _success ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),

View File

@@ -41,7 +41,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _fetchConsentInfo() async { Future<void> _fetchConsentInfo() async {
try { try {
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge); final info = await AuthProxyService.getConsentInfo(
widget.consentChallenge,
);
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동 // [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
if (info['redirectTo'] != null) { if (info['redirectTo'] != null) {
@@ -56,7 +58,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
details.forEach((scope, detail) { details.forEach((scope, detail) {
if (detail is Map<String, dynamic>) { if (detail is Map<String, dynamic>) {
// 설명 업데이트 // 설명 업데이트
if (detail['description'] != null && detail['description'].toString().isNotEmpty) { if (detail['description'] != null &&
detail['description'].toString().isNotEmpty) {
_scopeDescriptions[scope] = detail['description'].toString(); _scopeDescriptions[scope] = detail['description'].toString();
} }
// 필수 여부 업데이트 // 필수 여부 업데이트
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
} }
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택 // 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? []; final requestedScopes =
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
_selectedScopes.addAll(requestedScopes); _selectedScopes.addAll(requestedScopes);
setState(() { setState(() {
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
if (confirmed == true) { if (confirmed == true) {
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
try { try {
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge); final resp = await AuthProxyService.rejectConsent(
widget.consentChallenge,
);
final redirectTo = resp['redirectTo']; final redirectTo = resp['redirectTo'];
if (redirectTo != null) { if (redirectTo != null) {
webWindow.redirectTo(redirectTo); webWindow.redirectTo(redirectTo);
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
} catch (e) { } catch (e) {
setState(() => _isSubmitting = false); setState(() => _isSubmitting = false);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')), context,
); ).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
} }
} }
} }
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱'; final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
final clientId = _consentInfo?['client']?['client_id'] ?? '-'; final clientId = _consentInfo?['client']?['client_id'] ?? '-';
final clientLogo = _consentInfo?['client']?['logo_uri']; final clientLogo = _consentInfo?['client']?['logo_uri'];
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? []; final requestedScopes =
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
[];
return SingleChildScrollView( return SingleChildScrollView(
child: Container( child: Container(
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 8, elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Column( child: Column(
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
), ),
child: Row( child: Row(
children: [ children: [
if (clientLogo != null && clientLogo.toString().isNotEmpty) if (clientLogo != null &&
clientLogo.toString().isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: CircleAvatar( child: CircleAvatar(
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
children: [ children: [
const Text( const Text(
'요청된 권한', '요청된 권한',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
Text( Text(
'${requestedScopes.length}', '${requestedScopes.length}',
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
) )
: const Text( : const Text(
'동의하고 계속하기', '동의하고 계속하기',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -37,7 +37,10 @@ class ErrorScreen extends StatelessWidget {
fallback: '오류: {{code}}', fallback: '오류: {{code}}',
params: {'code': normalizedCode}, params: {'code': normalizedCode},
) )
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다')); : tr(
'msg.userfront.error.title_generic',
fallback: '오류가 발생했습니다',
));
final detail = isProd final detail = isProd
? (isWhitelisted ? (isWhitelisted
? tr( ? tr(
@@ -51,7 +54,10 @@ class ErrorScreen extends StatelessWidget {
: ((description?.isNotEmpty == true) : ((description?.isNotEmpty == true)
? description! ? description!
: (hasCode : (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.') ? tr(
'msg.userfront.error.detail_generic',
fallback: '오류가 발생했습니다.',
)
: tr( : tr(
'msg.userfront.error.detail_request', 'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.', fallback: '요청을 처리하는 중 문제가 발생했습니다.',
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF111827), backgroundColor: const Color(0xFF111827),
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
child: Text( child: Text(
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'), tr(
'ui.userfront.error.go_login',
fallback: '로그인으로 이동',
),
), ),
), ),
OutlinedButton( OutlinedButton(
onPressed: () => context.go('/'), onPressed: () => context.go('/'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827), foregroundColor: const Color(0xFF111827),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: const BorderSide(color: Color(0xFFCBD5F5)), side: const BorderSide(color: Color(0xFFCBD5F5)),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),

View File

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

View File

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

View File

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

View File

@@ -226,7 +226,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
title, title,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color), style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
@@ -268,7 +272,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
controller: controller, controller: controller,
onDetect: _onDetect, onDetect: _onDetect,
errorBuilder: (context, error) { errorBuilder: (context, error) {
final isPermissionDenied = error.errorCode == final isPermissionDenied =
error.errorCode ==
MobileScannerErrorCode.permissionDenied; MobileScannerErrorCode.permissionDenied;
return Center( return Center(
child: Column( child: Column(
@@ -295,7 +300,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
: _requestCameraPermission, : _requestCameraPermission,
child: Text( child: Text(
_isRequestingCamera _isRequestingCamera
? tr('ui.common.requesting', fallback: '요청 중...') ? tr(
'ui.common.requesting',
fallback: '요청 중...',
)
: tr( : tr(
'ui.userfront.qr.request_permission', 'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기', fallback: '카메라 권한 요청하기',

View File

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

View File

@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
final email = _emailController.text.trim(); final email = _emailController.text.trim();
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) { if (!emailRegex.hasMatch(email)) {
setState(() => _emailError = tr( setState(
() => _emailError = tr(
'msg.userfront.signup.email.invalid', 'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.', fallback: '유효한 이메일 형식이 아닙니다.',
)); ),
);
return; return;
} }
setState(() { _isLoading = true; _emailError = null; }); setState(() {
_isLoading = true;
_emailError = null;
});
try { try {
final available = await AuthProxyService.checkEmailAvailability(email); final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) { if (!available) {
setState(() => _emailError = tr( setState(
() => _emailError = tr(
'msg.userfront.signup.email.duplicate', 'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.', fallback: '이미 가입된 이메일입니다.',
)); ),
);
return; return;
} }
await AuthProxyService.sendSignupCode(email, 'email'); await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email'); _startTimer('email');
} catch (e) { } catch (e) {
setState(() => _emailError = tr( setState(
() => _emailError = tr(
'msg.userfront.signup.email.send_failed', 'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}', fallback: '발송 실패: {{error}}',
params: {'error': e.toString()}, params: {'error': e.toString()},
)); ),
);
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _emailCodeController.text.trim(); final code = _emailCodeController.text.trim();
if (code.length != 6) return; if (code.length != 6) return;
try { try {
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code); final success = await AuthProxyService.verifySignupCode(
_emailController.text.trim(),
'email',
code,
);
if (success) { if (success) {
setState(() { setState(() {
_isEmailVerified = true; _isEmailVerified = true;
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
_emailError = null; _emailError = null;
}); });
} else { } else {
setState(() => _emailError = tr( setState(
() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch', 'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.', fallback: '인증코드가 일치하지 않습니다.',
)); ),
);
} }
} catch (e) { } catch (e) {
setState(() => _emailError = tr( setState(
() => _emailError = tr(
'msg.userfront.signup.email.verify_failed', 'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}', fallback: '인증 실패: {{error}}',
params: {'error': e.toString()}, params: {'error': e.toString()},
)); ),
);
} }
} }
Future<void> _sendPhoneCode() async { Future<void> _sendPhoneCode() async {
final phone = _phoneController.text.trim(); final phone = _phoneController.text.trim();
if (phone.isEmpty) return; if (phone.isEmpty) return;
setState(() { _isLoading = true; _phoneError = null; }); setState(() {
_isLoading = true;
_phoneError = null;
});
try { try {
await AuthProxyService.sendSignupCode(phone, 'phone'); await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone'); _startTimer('phone');
} catch (e) { } catch (e) {
setState(() => _phoneError = tr( setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed', 'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}', fallback: '발송 실패: {{error}}',
params: {'error': e.toString()}, params: {'error': e.toString()},
)); ),
);
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
final code = _phoneCodeController.text.trim(); final code = _phoneCodeController.text.trim();
if (code.length != 6) return; if (code.length != 6) return;
try { try {
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code); final success = await AuthProxyService.verifySignupCode(
_phoneController.text.trim(),
'phone',
code,
);
if (success) { if (success) {
setState(() { setState(() {
_isPhoneVerified = true; _isPhoneVerified = true;
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneError = null; _phoneError = null;
}); });
} else { } else {
setState(() => _phoneError = tr( setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch', 'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.', fallback: '인증코드가 일치하지 않습니다.',
)); ),
);
} }
} catch (e) { } catch (e) {
setState(() => _phoneError = tr( setState(
() => _phoneError = tr(
'msg.userfront.signup.phone.verify_failed', 'msg.userfront.signup.phone.verify_failed',
fallback: '인증 실패: {{error}}', fallback: '인증 실패: {{error}}',
params: {'error': e.toString()}, params: {'error': e.toString()},
)); ),
);
} }
} }
Future<void> _handleSignup() async { Future<void> _handleSignup() async {
if (_passwordController.text != _confirmPasswordController.text) { if (_passwordController.text != _confirmPasswordController.text) {
setState(() => _confirmPasswordError = tr( setState(
() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch', 'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.', fallback: '비밀번호가 일치하지 않습니다.',
)); ),
);
return; return;
} }
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
phone: _phoneController.text.trim(), phone: _phoneController.text.trim(),
affiliationType: _affiliationType, affiliationType: _affiliationType,
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null, companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(), department: _deptController.text.trim().isEmpty
? (_affiliationType == 'GENERAL' ? 'External' : '')
: _deptController.text.trim(),
termsAccepted: true, termsAccepted: true,
); );
if (mounted) _showSuccessDialog(); if (mounted) _showSuccessDialog();
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 12, radius: 12,
backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]), backgroundColor: isDone
child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)), ? Colors.green
: (isCurrent ? Colors.black : Colors.grey[300]),
child: isDone
? const Icon(Icons.check, size: 14, color: Colors.white)
: Text(
'$step',
style: TextStyle(
color: isCurrent ? Colors.white : Colors.black54,
fontSize: 10,
),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)), Text(
label,
style: TextStyle(
fontSize: 9,
color: isCurrent ? Colors.black : Colors.grey,
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
),
),
], ],
); );
} }
@@ -438,10 +489,7 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
child: CheckboxListTile( child: CheckboxListTile(
title: Text( title: Text(
tr( tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
'ui.userfront.signup.agreement.all',
fallback: '모두 동의합니다',
),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
), ),
value: _termsAccepted && _privacyAccepted, value: _termsAccepted && _privacyAccepted,
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
return Column( return Column(
children: [ children: [
CheckboxListTile( CheckboxListTile(
title: Text(title, title: Text(
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Text( child: Text(
content, content,
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5), style: const TextStyle(
fontSize: 12,
color: Colors.grey,
height: 1.5,
),
), ),
), ),
), ),
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 가족사 이메일 안내 문구 // 가족사 이메일 안내 문구
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)), decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(6),
),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.info_outline, size: 16, color: Colors.blue), const Icon(Icons.info_outline, size: 16, color: Colors.blue),
@@ -730,7 +787,11 @@ class _SignupScreenState extends State<SignupScreen> {
'msg.userfront.signup.auth.affiliate_notice', 'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.', fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
), ),
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500), style: const TextStyle(
fontSize: 12,
color: Colors.blue,
fontWeight: FontWeight.w500,
),
), ),
), ),
], ],
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox( SizedBox(
height: 55, height: 55,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode, onPressed: (_isEmailVerified || _isLoading)
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), ? null
: _sendEmailCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text( child: Text(
_emailSeconds > 0 _emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송') ? tr('ui.common.resend', fallback: '재발송')
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], inputFormatters: [
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); }, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyEmailCode();
},
), ),
], ],
if (_isEmailVerified) if (_isEmailVerified)
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
SizedBox( SizedBox(
height: 55, height: 55,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode, onPressed: (_isPhoneVerified || _isLoading)
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), ? null
: _sendPhoneCode,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[100],
foregroundColor: Colors.black,
elevation: 0,
),
child: Text( child: Text(
_phoneSeconds > 0 _phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송') ? tr('ui.common.resend', fallback: '재발송')
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], inputFormatters: [
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); }, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
onChanged: (val) {
if (val.length == 6) _verifyPhoneCode();
},
), ),
], ],
if (_isPhoneVerified) if (_isPhoneVerified)
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _nameController, controller: _nameController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: InputDecoration( decoration: InputDecoration(
labelText: tr( labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
'ui.userfront.signup.profile.name',
fallback: '이름',
),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
), ),
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
DropdownMenuItem( DropdownMenuItem(
value: 'GENERAL', value: 'GENERAL',
child: Text( child: Text(
tr( tr('domain.affiliation.general', fallback: '일반 사용자'),
'domain.affiliation.general',
fallback: '일반 사용자',
),
), ),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'AFFILIATE', value: 'AFFILIATE',
child: Text( child: Text(
tr( tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
'domain.affiliation.affiliate',
fallback: '가족사 임직원',
),
), ),
), ),
], ],
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'HANMAC', value: 'HANMAC',
child: Text( child: Text(tr('domain.company.hanmac', fallback: '한맥')),
tr('domain.company.hanmac', fallback: '한맥'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'SAMAN', value: 'SAMAN',
child: Text( child: Text(tr('domain.company.saman', fallback: '삼안')),
tr('domain.company.saman', fallback: '삼안'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'PTC', value: 'PTC',
child: Text( child: Text(tr('domain.company.ptc', fallback: 'PTC')),
tr('domain.company.ptc', fallback: 'PTC'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'JANGHEON', value: 'JANGHEON',
child: Text( child: Text(tr('domain.company.jangheon', fallback: '장헌')),
tr('domain.company.jangheon', fallback: '장헌'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'BARON', value: 'BARON',
child: Text( child: Text(tr('domain.company.baron', fallback: '바론')),
tr('domain.company.baron', fallback: '바론'),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 'HALLA', value: 'HALLA',
child: Text( child: Text(tr('domain.company.halla', fallback: '한라')),
tr('domain.company.halla', fallback: '한라'),
),
), ),
], ],
onChanged: _isAffiliateEmail onChanged: _isAffiliateEmail
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
'ui.userfront.signup.profile.department_optional', 'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)', fallback: '소속 정보 (선택)',
), ),
border: const OutlineInputBorder() border: const OutlineInputBorder(),
), ),
), ),
], ],
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
); );
} }
if (requiresUpper) { if (requiresUpper) {
parts.add( parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
tr(
'msg.userfront.signup.policy.uppercase',
fallback: '대문자',
),
);
} }
if (requiresLower) { if (requiresLower) {
parts.add( parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
tr(
'msg.userfront.signup.policy.lowercase',
fallback: '소문자',
),
);
} }
if (requiresNumber) { if (requiresNumber) {
parts.add( parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
tr(
'msg.userfront.signup.policy.number',
fallback: '숫자',
),
);
} }
if (requiresSymbol) { if (requiresSymbol) {
parts.add( parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
tr(
'msg.userfront.signup.policy.symbol',
fallback: '특수문자',
),
);
} }
return tr( return tr(
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
// 비밀번호 정책 안내 박스 // 비밀번호 정책 안내 박스
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.security, size: 18, color: Colors.blue), const Icon(Icons.security, size: 18, color: Colors.blue),
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded( Expanded(
child: Text( child: Text(
_buildPolicyDescription(), _buildPolicyDescription(),
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500), style: TextStyle(
fontSize: 12,
color: Colors.blue[800],
fontWeight: FontWeight.w500,
),
), ),
), ),
], ],
@@ -1219,10 +1268,7 @@ class _SignupScreenState extends State<SignupScreen> {
), ),
if (requiresNumber) if (requiresNumber)
_cryptoCheck( _cryptoCheck(
tr( tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
'msg.userfront.signup.password.rule.number',
fallback: '숫자',
),
hasDigit, hasDigit,
), ),
if (requiresSymbol) if (requiresSymbol)
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey), Icon(
isValid ? Icons.check_circle : Icons.circle_outlined,
size: 14,
color: isValid ? Colors.green : Colors.grey,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)), Text(
label,
style: TextStyle(
fontSize: 11,
color: isValid ? Colors.green : Colors.grey,
),
),
], ],
); );
} }
@@ -1276,8 +1332,10 @@ class _SignupScreenState extends State<SignupScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool canGoNext = false; bool canGoNext = false;
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true; if (_currentStep == 1 && _termsAccepted && _privacyAccepted)
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true; canGoNext = true;
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified)
canGoNext = true;
if (_currentStep == 3) { if (_currentStep == 3) {
final nameOk = _nameController.text.trim().isNotEmpty; final nameOk = _nameController.text.trim().isNotEmpty;
if (_affiliationType == 'GENERAL') { if (_affiliationType == 'GENERAL') {
@@ -1317,7 +1375,9 @@ class _SignupScreenState extends State<SignupScreen> {
? _buildStepAgreement() ? _buildStepAgreement()
: (_currentStep == 2 : (_currentStep == 2
? _buildStepAuth() ? _buildStepAuth()
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())), : (_currentStep == 3
? _buildStepInfo()
: _buildStepPassword())),
), ),
), ),
), ),
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: () => setState(() => _currentStep--), onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)), style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(55),
side: const BorderSide(color: Colors.black),
),
child: Text( child: Text(
tr('ui.common.prev', fallback: '이전'), tr('ui.common.prev', fallback: '이전'),
style: const TextStyle(color: Colors.black), style: const TextStyle(color: Colors.black),
@@ -1341,18 +1404,33 @@ class _SignupScreenState extends State<SignupScreen> {
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: _currentStep < 4 onPressed: _currentStep < 4
? (canGoNext ? () => setState(() => _currentStep++) : null) ? (canGoNext
? () => setState(() => _currentStep++)
: null)
: (_isLoading ? null : _handleSignup), : (_isLoading ? null : _handleSignup),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(55), minimumSize: const Size.fromHeight(55),
backgroundColor: Colors.black, backgroundColor: Colors.black,
), ),
child: _isLoading child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text( : Text(
_currentStep < 4 _currentStep < 4
? tr('ui.userfront.signup.next_step', fallback: '다음 단계') ? tr(
: tr('ui.userfront.signup.complete', fallback: '가입 완료'), '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'); String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async { Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
final queryParameters = <String, String>{ final queryParameters = <String, String>{'limit': '20'};
'limit': '20',
};
if (cursor != null && cursor.isNotEmpty) { if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor; queryParameters['cursor'] = cursor;
} }
final url = Uri.parse('$_baseUrl/api/v1/audit/auth/timeline') final url = Uri.parse(
.replace(queryParameters: queryParameters); '$_baseUrl/api/v1/audit/auth/timeline',
).replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie(); final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -60,10 +57,6 @@ Future<AuditPage> _fetchAuthTimelinePage({String? cursor}) async {
} }
} }
typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor}); typedef AuthTimelineFetcher = Future<AuditPage> Function({String? cursor});
final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) { final authTimelineFetcherProvider = Provider<AuthTimelineFetcher>((ref) {
@@ -188,6 +181,7 @@ class AuthTimelineNotifier extends Notifier<AuthTimelineState> {
} }
} }
final authTimelineProvider = NotifierProvider<AuthTimelineNotifier, AuthTimelineState>( final authTimelineProvider =
NotifierProvider<AuthTimelineNotifier, AuthTimelineState>(
AuthTimelineNotifier.new, AuthTimelineNotifier.new,
); );

View File

@@ -69,9 +69,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
final token = AuthTokenStore.getToken(); final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie); final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{ final headers = <String, String>{'Content-Type': 'application/json'};
'Content-Type': 'application/json',
};
if (!useCookie && token != null) { if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
} }
@@ -106,6 +104,7 @@ class LinkedRpsNotifier extends AsyncNotifier<List<LinkedRp>> {
} }
} }
final linkedRpsProvider = AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() { final linkedRpsProvider =
AsyncNotifierProvider<LinkedRpsNotifier, List<LinkedRp>>(() {
return LinkedRpsNotifier(); return LinkedRpsNotifier();
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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