1
0
forked from baron/baron-sso

Merge pull request 'featur/tenantsign' (#284) from featur/tenantsign into dev

Reviewed-on: baron/baron-sso#284
This commit is contained in:
2026-02-20 09:46:44 +09:00
25 changed files with 605 additions and 465 deletions

View File

@@ -110,7 +110,7 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# OIDC 클라이언트 callback (콤마 구분)
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/callback,https://sso.hmac.kr/devfront/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
@@ -134,9 +134,9 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
CSRF_COOKIE_SECRET=localcsrf123
# AdminFront OIDC 설정
ADMINFRONT_CALLBACK_URLS=http://localhost:5000/callback,https://sso.hmac.kr/devfront/callback
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
# DevFront OIDC 설정
VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_CALLBACK_URLS=http://localhost:5174/callback,https://sso.hmac.kr/devfront/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback

View File

@@ -20,9 +20,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -3163,6 +3165,15 @@
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -3605,6 +3616,18 @@
"node": ">= 6"
}
},
"node_modules/oidc-client-ts": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3926,6 +3949,19 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-oidc-context": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz",
"integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"oidc-client-ts": "^3.1.0",
"react": ">=16.14.0"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@@ -26,9 +26,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",

View File

@@ -44,12 +44,8 @@ function LanguageSelector() {
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.language", "언어")}
>
<option value="ko">
{t("ui.common.language_ko", "한국어")}
</option>
<option value="en">
{t("ui.common.language_en", "English")}
</option>
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
<option value="en">{t("ui.common.language_en", "English")}</option>
</select>
);
}

View File

@@ -12,6 +12,7 @@ import {
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector";
@@ -40,6 +41,7 @@ const navItems = [
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const auth = useAuth();
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
@@ -51,16 +53,16 @@ function AppLayout() {
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
) {
window.localStorage.removeItem("admin_session");
auth.removeUser();
navigate("/login");
}
};
useEffect(() => {
const session = window.localStorage.getItem("admin_session");
if (!session) {
if (!auth.isLoading && !auth.isAuthenticated) {
navigate("/login");
}
}, [navigate]);
}, [auth.isLoading, auth.isAuthenticated, navigate]);
useEffect(() => {
const root = document.documentElement;
@@ -77,6 +79,14 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
if (auth.isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
);
}
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils"
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -20,12 +20,12 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
className,
)}
{...props}
>
@@ -48,8 +48,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
@@ -58,12 +58,12 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
className,
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
@@ -72,12 +72,12 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@@ -87,12 +87,12 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
className,
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@@ -103,8 +103,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
@@ -117,4 +117,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};

View File

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

View File

@@ -1,14 +1,14 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils"
import { cn } from "../../lib/utils";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
className,
)}
{...props}
>
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=top]:slide-in-from-bottom-2 data-[state=bottom]:slide-in-from-top-2",
position === "popper" &&
"data-[state=open]:slide-in-from-top-2 data-[state=bottom]:translate-y-1 data-[state=left]:-translate-x-1 data-[state=right]:translate-x-1 data-[state=top]:-translate-y-1",
className
className,
)}
position={position}
{...props}
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@@ -106,8 +106,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
className,
)}
{...props}
>
@@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -141,8 +141,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@@ -155,4 +155,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
};

View File

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

View File

@@ -1,29 +1,25 @@
import { ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
function AuthCallbackPage() {
const auth = useAuth();
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 });
if (auth.isAuthenticated) {
// Save token to localStorage for existing API clients that might still use it
const user = auth.user;
if (user?.access_token) {
window.localStorage.setItem("admin_session", user.access_token);
}
} else {
console.error("No token found in callback URL");
navigate("/", { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [navigate, searchParams]);
}, [auth.isAuthenticated, auth.error, navigate, auth.user]);
return (
<div className="flex min-h-screen items-center justify-center bg-background">

View File

@@ -1,6 +1,5 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "react-oidc-context";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -11,53 +10,11 @@ import {
} 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 auth = useAuth();
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("팝업 차단이 설정되어 있습니다. 팝업 허용 후 다시 시도해 주세요.");
}
// OIDC client-side authentication flow started here
auth.signinRedirect();
};
return (
@@ -89,9 +46,9 @@ function LoginPage() {
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={isLoggingIn}
disabled={auth.isLoading}
>
{isLoggingIn ? (
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...

View File

@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, Users } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -18,8 +19,7 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { fetchTenants, fetchGroups } from "../../../lib/adminApi";
import { useState } from "react";
import { fetchGroups, fetchTenants } from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() {
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
@@ -27,7 +27,8 @@ export default function GlobalUserGroupListPage() {
queryFn: () => fetchTenants(100, 0),
});
if (isTenantsLoading) return <div className="p-8">Loading tenants and groups...</div>;
if (isTenantsLoading)
return <div className="p-8">Loading tenants and groups...</div>;
return (
<div className="space-y-8">
@@ -35,7 +36,8 @@ export default function GlobalUserGroupListPage() {
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
<p className="text-muted-foreground">
. .
.
.
</p>
</div>
</header>
@@ -62,7 +64,9 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
<CardTitle className="text-xl flex items-center gap-2">
<Building2 size={20} className="text-muted-foreground" />
{tenant.name}
<Badge variant="outline" className="ml-2">{tenant.slug}</Badge>
<Badge variant="outline" className="ml-2">
{tenant.slug}
</Badge>
</CardTitle>
<CardDescription>
.
@@ -88,11 +92,16 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">Loading...</TableCell>
<TableCell colSpan={4} className="text-center">
Loading...
</TableCell>
</TableRow>
) : groups?.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-4">
<TableCell
colSpan={4}
className="text-center text-muted-foreground py-4"
>
.
</TableCell>
</TableRow>
@@ -102,7 +111,10 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={14} className="text-primary" />
<Link to={`/tenants/${tenant.id}/user-groups/${group.id}`} className="hover:underline">
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
className="hover:underline"
>
{group.name}
</Link>
</div>
@@ -111,7 +123,11 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
<TableCell>{group.members?.length || 0} </TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link to={`/tenants/${tenant.id}/user-groups/${group.id}`}></Link>
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
>
</Link>
</Button>
</TableCell>
</TableRow>

View File

@@ -61,7 +61,11 @@ export function UserGroupDetailPage() {
const [selectedRelation, setSelectedRelation] = useState("view");
// Fetch specific group details
const { data: currentGroup, isLoading: isGroupLoading, error } = useQuery({
const {
data: currentGroup,
isLoading: isGroupLoading,
error,
} = useQuery({
queryKey: ["user-group-detail", id],
queryFn: () => fetchGroup(tenantId!, id!),
enabled: !!id && !!tenantId,
@@ -112,12 +116,7 @@ export function UserGroupDetailPage() {
const assignRoleMutation = useMutation({
mutationFn: () =>
assignGroupRole(
tenantId!,
id!,
selectedTargetTenantId,
selectedRelation,
),
assignGroupRole(tenantId!, id!, selectedTargetTenantId, selectedRelation),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
setIsAddRoleOpen(false);
@@ -137,29 +136,50 @@ export function UserGroupDetailPage() {
},
});
if (isGroupLoading) return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-3 text-muted-foreground">Loading group details...</span>
</div>
);
if (isGroupLoading)
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-3 text-muted-foreground">
Loading group details...
</span>
</div>
);
if (error || !currentGroup) return (
<div className="p-8 text-center space-y-4">
<h3 className="text-xl font-semibold text-destructive">Could not load group</h3>
<div className="p-4 bg-red-50 text-red-700 rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-red-100">
<p>Error: {(error as any)?.response?.data?.error || (error as any)?.message || "Not found"}</p>
<p className="mt-2 text-red-500 opacity-70">Path: /admin/tenants/{tenantId}/user-groups/{id}</p>
if (error || !currentGroup)
return (
<div className="p-8 text-center space-y-4">
<h3 className="text-xl font-semibold text-destructive">
Could not load group
</h3>
<div className="p-4 bg-red-50 text-red-700 rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-red-100">
<p>
Error:{" "}
{(error as any)?.response?.data?.error ||
(error as any)?.message ||
"Not found"}
</p>
<p className="mt-2 text-red-500 opacity-70">
Path: /admin/tenants/{tenantId}/user-groups/{id}
</p>
</div>
<p className="text-muted-foreground pt-2">
The group ID might be invalid or you don't have sufficient
permissions.
</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
<div className="pt-4 border-t">
<Link
to={`/tenants/${tenantId}/user-groups`}
className="text-primary hover:underline text-sm"
>
Return to Group List
</Link>
</div>
</div>
<p className="text-muted-foreground pt-2">The group ID might be invalid or you don't have sufficient permissions.</p>
<Button variant="outline" onClick={() => window.location.reload()}>Retry</Button>
<div className="pt-4 border-t">
<Link to={`/tenants/${tenantId}/user-groups`} className="text-primary hover:underline text-sm">
Return to Group List
</Link>
</div>
</div>
);
);
return (
<div className="space-y-8">
@@ -187,8 +207,8 @@ export function UserGroupDetailPage() {
</p>
</div>
<div className="flex gap-2">
<Badge variant="outline">User Group</Badge>
<Badge variant="muted">Tenant: {tenantId?.split('-')[0]}...</Badge>
<Badge variant="outline">User Group</Badge>
<Badge variant="muted">Tenant: {tenantId?.split("-")[0]}...</Badge>
</div>
</header>
@@ -217,15 +237,18 @@ export function UserGroupDetailPage() {
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Search User</Label>
<Input
placeholder="Search by email or name..."
<Input
placeholder="Search by email or name..."
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Select User</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<Select
value={selectedUserId}
onValueChange={setSelectedUserId}
>
<SelectTrigger>
<SelectValue placeholder="Choose a user" />
</SelectTrigger>
@@ -240,10 +263,13 @@ export function UserGroupDetailPage() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddMemberOpen(false)}>
<Button
variant="outline"
onClick={() => setIsAddMemberOpen(false)}
>
Cancel
</Button>
<Button
<Button
onClick={() => addMemberMutation.mutate(selectedUserId)}
disabled={!selectedUserId || addMemberMutation.isPending}
>
@@ -264,7 +290,10 @@ export function UserGroupDetailPage() {
<TableBody>
{!currentGroup.members || currentGroup.members.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 text-muted-foreground">
<TableCell
colSpan={2}
className="text-center py-4 text-muted-foreground"
>
No members in this group.
</TableCell>
</TableRow>
@@ -274,13 +303,15 @@ export function UserGroupDetailPage() {
<TableCell>
<div>
<p className="font-medium">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.email}</p>
<p className="text-xs text-muted-foreground">
{member.email}
</p>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeMemberMutation.mutate(member.id)}
>
@@ -300,7 +331,9 @@ export function UserGroupDetailPage() {
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Permissions</CardTitle>
<CardDescription>Tenant roles assigned to this group.</CardDescription>
<CardDescription>
Tenant roles assigned to this group.
</CardDescription>
</div>
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
<DialogTrigger asChild>
@@ -313,13 +346,17 @@ export function UserGroupDetailPage() {
<DialogHeader>
<DialogTitle>Assign Tenant Role</DialogTitle>
<DialogDescription>
Members of this group will inherit this role on the target tenant.
Members of this group will inherit this role on the target
tenant.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<div className="space-y-2">
<Label>Target Tenant</Label>
<Select value={selectedTargetTenantId} onValueChange={setSelectedTargetTenantId}>
<Select
value={selectedTargetTenantId}
onValueChange={setSelectedTargetTenantId}
>
<SelectTrigger>
<SelectValue placeholder="Select target tenant" />
</SelectTrigger>
@@ -334,25 +371,37 @@ export function UserGroupDetailPage() {
</div>
<div className="space-y-2">
<Label>Role (Relation)</Label>
<Select value={selectedRelation} onValueChange={setSelectedRelation}>
<Select
value={selectedRelation}
onValueChange={setSelectedRelation}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="view">View (Read-only)</SelectItem>
<SelectItem value="manage">Manage (Read/Write)</SelectItem>
<SelectItem value="admins">Admin (Full Control)</SelectItem>
<SelectItem value="manage">
Manage (Read/Write)
</SelectItem>
<SelectItem value="admins">
Admin (Full Control)
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddRoleOpen(false)}>
<Button
variant="outline"
onClick={() => setIsAddRoleOpen(false)}
>
Cancel
</Button>
<Button
<Button
onClick={() => assignRoleMutation.mutate()}
disabled={!selectedTargetTenantId || assignRoleMutation.isPending}
disabled={
!selectedTargetTenantId || assignRoleMutation.isPending
}
>
Assign
</Button>
@@ -371,10 +420,17 @@ export function UserGroupDetailPage() {
</TableHeader>
<TableBody>
{isRolesLoading ? (
<TableRow><TableCell colSpan={3} className="text-center">Loading...</TableCell></TableRow>
<TableRow>
<TableCell colSpan={3} className="text-center">
Loading...
</TableCell>
</TableRow>
) : !groupRoles || groupRoles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-4 text-muted-foreground">
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
No roles assigned.
</TableCell>
</TableRow>
@@ -382,17 +438,26 @@ export function UserGroupDetailPage() {
groupRoles.map((role, idx) => (
<TableRow key={`${role.tenantId}-${role.relation}-${idx}`}>
<TableCell>
<div className="font-medium">{role.tenantName || role.tenantId}</div>
<div className="font-medium">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">{role.relation}</Badge>
<Badge variant="outline" className="capitalize">
{role.relation}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeRoleMutation.mutate({ targetTenantId: role.tenantId, relation: role.relation })}
onClick={() =>
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
})
}
>
<Trash2 size={14} />
</Button>

View File

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

View File

@@ -208,7 +208,9 @@ export async function createGroup(
}
export async function deleteGroup(tenantId: string, groupId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`);
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`,
);
}
export async function addGroupMember(

View File

@@ -0,0 +1,20 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
};
export const userManager = new UserManager({
...oidcConfig,
authority: oidcConfig.authority || "",
client_id: oidcConfig.client_id || "",
redirect_uri: oidcConfig.redirect_uri || "",
});

View File

@@ -1,9 +1,11 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { oidcConfig } from "./lib/auth";
import "./index.css";
const rootElement = document.getElementById("root");
@@ -14,8 +16,10 @@ if (!rootElement) {
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
<AuthProvider {...oidcConfig}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,148 @@
package handler
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// --- Local Mocks for Signup Test ---
type MockRedisForSignup struct {
mock.Mock
}
func (m *MockRedisForSignup) Set(key string, value string, ttl time.Duration) error {
return m.Called(key, value, ttl).Error(0)
}
func (m *MockRedisForSignup) Get(key string) (string, error) {
args := m.Called(key)
return args.String(0), args.Error(1)
}
func (m *MockRedisForSignup) Delete(key string) error {
return m.Called(key).Error(0)
}
func (m *MockRedisForSignup) StoreVerificationCode(phone, code string) error { return nil }
func (m *MockRedisForSignup) GetVerificationCode(phone string) (string, error) { return "", nil }
func (m *MockRedisForSignup) DeleteVerificationCode(phone string) error { return nil }
func (m *MockRedisForSignup) Ping(ctx context.Context) error { return nil }
type MockIdpForSignup struct {
mock.Mock
}
func (m *MockIdpForSignup) Name() string { return "mock-idp" }
func (m *MockIdpForSignup) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{SupportedFields: []string{"email", "name", "phoneNumber", "grade", "department"}}, nil
}
func (m *MockIdpForSignup) CreateUser(user *domain.BrokerUser, password string) (string, error) {
args := m.Called(user, password)
return args.String(0), args.Error(1)
}
func (m *MockIdpForSignup) SignIn(loginID, password string) (*domain.AuthInfo, error) {
return nil, nil
}
func (m *MockIdpForSignup) UserExists(loginID string) (bool, error) { return false, nil }
func (m *MockIdpForSignup) IssueSession(loginID string) (*domain.AuthInfo, error) { return nil, nil }
func (m *MockIdpForSignup) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) {
return nil, nil
}
func (m *MockIdpForSignup) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
return nil, nil
}
func (m *MockIdpForSignup) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
return &domain.PasswordPolicy{MinLength: 12}, nil
}
func (m *MockIdpForSignup) InitiatePasswordReset(loginID, redirectUrl string) error { return nil }
func (m *MockIdpForSignup) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
return nil, nil
}
func (m *MockIdpForSignup) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
return nil
}
func TestSignup_CompanyCodeValidation(t *testing.T) {
app := fiber.New()
mockTenantSvc := new(MockTenantService)
mockRedis := new(MockRedisForSignup)
mockIdp := new(MockIdpForSignup)
h := &AuthHandler{
TenantService: mockTenantSvc,
RedisService: mockRedis,
IdpProvider: mockIdp,
}
app.Post("/signup", h.Signup)
// Prepare mock state (already verified email/phone)
verifiedState, _ := json.Marshal(map[string]interface{}{
"verified": true,
"expires_at": time.Now().Add(time.Hour).Unix(),
})
mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil)
t.Run("Invalid Company Code", func(t *testing.T) {
reqBody := domain.SignupRequest{
Email: "user@gmail.com", // General domain
Password: "StrongPass123!",
Name: "Test User",
Phone: "010-1234-5678",
TermsAccepted: true,
CompanyCode: "non-existent-code",
}
body, _ := json.Marshal(reqBody)
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "non-existent-code").Return(nil, nil)
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var res map[string]interface{}
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, "Invalid company code.", res["error"])
})
t.Run("Active Company Code", func(t *testing.T) {
reqBody := domain.SignupRequest{
Email: "user@gmail.com",
Password: "StrongPass123!",
Name: "Test User",
Phone: "010-1234-5678",
TermsAccepted: true,
CompanyCode: "valid-slug",
}
body, _ := json.Marshal(reqBody)
validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive}
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil)
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil)
mockRedis.On("Delete", mock.Anything).Return(nil)
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}

View File

@@ -7,24 +7,23 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
type tenantServiceTenantRepoMock struct {
// --- Local Mocks to avoid collisions ---
type MockTenantRepoForSvc struct {
mock.Mock
}
func (m *tenantServiceTenantRepoMock) Create(ctx context.Context, tenant *domain.Tenant) error {
args := m.Called(ctx, tenant)
return args.Error(0)
func (m *MockTenantRepoForSvc) Create(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0)
}
func (m *tenantServiceTenantRepoMock) Update(ctx context.Context, tenant *domain.Tenant) error {
args := m.Called(ctx, tenant)
return args.Error(0)
func (m *MockTenantRepoForSvc) Update(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0)
}
func (m *tenantServiceTenantRepoMock) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
func (m *MockTenantRepoForSvc) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
@@ -32,7 +31,7 @@ func (m *tenantServiceTenantRepoMock) FindByID(ctx context.Context, id string) (
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *tenantServiceTenantRepoMock) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
func (m *MockTenantRepoForSvc) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
args := m.Called(ctx, slug)
if args.Get(0) == nil {
return nil, args.Error(1)
@@ -40,15 +39,11 @@ func (m *tenantServiceTenantRepoMock) FindBySlug(ctx context.Context, slug strin
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *tenantServiceTenantRepoMock) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
args := m.Called(ctx, name)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
func (m *MockTenantRepoForSvc) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
return nil, nil
}
func (m *tenantServiceTenantRepoMock) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
args := m.Called(ctx, domainName)
if args.Get(0) == nil {
return nil, args.Error(1)
@@ -56,211 +51,143 @@ func (m *tenantServiceTenantRepoMock) FindByDomain(ctx context.Context, domainNa
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *tenantServiceTenantRepoMock) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
args := m.Called(ctx, ids)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.Tenant), args.Error(1)
func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
return nil, nil
}
func (m *tenantServiceTenantRepoMock) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
args := m.Called(ctx, tenantID, domainName, verified)
return args.Error(0)
func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
return m.Called(ctx, tenantID, domainName, verified).Error(0)
}
type tenantServiceUserRepoMock struct {
type MockKetoSvcForTenant struct {
mock.Mock
}
func (m *tenantServiceUserRepoMock) Create(ctx context.Context, user *domain.User) error {
args := m.Called(ctx, user)
return args.Error(0)
func (m *MockKetoSvcForTenant) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *tenantServiceUserRepoMock) Update(ctx context.Context, user *domain.User) error {
args := m.Called(ctx, user)
return args.Error(0)
func (m *MockKetoSvcForTenant) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *tenantServiceUserRepoMock) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
args := m.Called(ctx, email)
func (m *MockKetoSvcForTenant) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]RelationTuple), args.Error(1)
}
func (m *MockKetoSvcForTenant) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1)
}
func (m *MockKetoSvcForTenant) CheckPermission(ctx context.Context, namespace, object, relation, subject string) (bool, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Bool(0), args.Error(1)
}
type MockUserRepoForTenant struct {
mock.Mock
}
func (m *MockUserRepoForTenant) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForTenant) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForTenant) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
args := m.Called(email)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.User), args.Error(1)
}
func (m *tenantServiceUserRepoMock) FindByID(ctx context.Context, id string) (*domain.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.User), args.Error(1)
func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) {
return nil, nil
}
func (m *tenantServiceUserRepoMock) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
args := m.Called(ctx, ids)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.User), args.Error(1)
func (m *MockUserRepoForTenant) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
return nil, nil
}
func (m *tenantServiceUserRepoMock) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
args := m.Called(ctx, tenantID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.User), args.Error(1)
func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
return nil, nil
}
func (m *tenantServiceUserRepoMock) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
args := m.Called(ctx, offset, limit, search)
if args.Get(0) == nil {
return nil, 0, args.Error(2)
}
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
return nil, 0, nil
}
func TestTenantService_RegisterTenant_AddsDomainsAsVerified(t *testing.T) {
repo := new(tenantServiceTenantRepoMock)
userRepo := new(tenantServiceUserRepoMock)
svc := NewTenantService(repo, userRepo)
// --- Tests ---
repo.On("FindBySlug", mock.Anything, "tenant-a").Return(nil, gorm.ErrRecordNotFound).Once()
repo.On("Create", mock.Anything, mock.MatchedBy(func(tenant *domain.Tenant) bool {
return tenant.Name == "Tenant A" &&
tenant.Slug == "tenant-a" &&
tenant.Status == domain.TenantStatusActive
})).Run(func(args mock.Arguments) {
args.Get(1).(*domain.Tenant).ID = "tenant-1"
}).Return(nil).Once()
repo.On("AddDomain", mock.Anything, "tenant-1", "a.example.com", true).Return(nil).Once()
repo.On("AddDomain", mock.Anything, "tenant-1", "a.example.org", true).Return(nil).Once()
repo.On("FindBySlug", mock.Anything, "tenant-a").Return(&domain.Tenant{
ID: "tenant-1",
Name: "Tenant A",
Slug: "tenant-a",
Status: domain.TenantStatusActive,
}, nil).Once()
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
svc := NewTenantService(mockRepo, nil)
tenant, err := svc.RegisterTenant(context.Background(), "Tenant A", "tenant-a", "desc", []string{"a.example.com", "a.example.org"})
ctx := context.Background()
name := "New Tenant"
slug := "new-tenant"
domains := []string{"example.com"}
// Use .Once() to ensure correct return values for sequential calls to FindBySlug
mockRepo.On("FindBySlug", ctx, slug).Return(nil, nil).Once()
mockRepo.On("Create", ctx, mock.Anything).Return(nil)
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
tenant, err := svc.RegisterTenant(ctx, name, slug, "", domains)
assert.NoError(t, err)
assert.NotNil(t, tenant)
assert.Equal(t, "tenant-1", tenant.ID)
repo.AssertExpectations(t)
assert.Equal(t, "t1", tenant.ID)
mockRepo.AssertExpectations(t)
}
func TestTenantService_RequestRegistration_AddsDomainAsUnverified(t *testing.T) {
repo := new(tenantServiceTenantRepoMock)
userRepo := new(tenantServiceUserRepoMock)
svc := NewTenantService(repo, userRepo)
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
svc := NewTenantService(mockRepo, nil)
repo.On("Create", mock.Anything, mock.MatchedBy(func(tenant *domain.Tenant) bool {
return tenant.Name == "Tenant B" &&
tenant.Slug == "tenant-b" &&
tenant.Status == domain.TenantStatusPending &&
tenant.Config["adminEmail"] == "admin@tenant-b.com"
})).Run(func(args mock.Arguments) {
args.Get(1).(*domain.Tenant).ID = "tenant-2"
}).Return(nil).Once()
repo.On("AddDomain", mock.Anything, "tenant-2", "tenant-b.com", false).Return(nil).Once()
ctx := context.Background()
name := "Public Tenant"
slug := "public-tenant"
domainName := "public.com"
adminEmail := "admin@public.com"
tenant, err := svc.RequestRegistration(
context.Background(),
"Tenant B",
"tenant-b",
"desc",
"tenant-b.com",
"admin@tenant-b.com",
)
mockRepo.On("Create", ctx, mock.MatchedBy(func(tenant *domain.Tenant) bool {
return tenant.Status == domain.TenantStatusPending
})).Return(nil)
mockRepo.On("AddDomain", ctx, mock.Anything, domainName, false).Return(nil)
tenant, err := svc.RequestRegistration(ctx, name, slug, "", domainName, adminEmail)
assert.NoError(t, err)
assert.NotNil(t, tenant)
assert.Equal(t, "tenant-2", tenant.ID)
assert.Equal(t, domain.TenantStatusPending, tenant.Status)
repo.AssertExpectations(t)
mockRepo.AssertExpectations(t)
}
func TestTenantService_RequestRegistration_RejectsDomainMismatch(t *testing.T) {
repo := new(tenantServiceTenantRepoMock)
userRepo := new(tenantServiceUserRepoMock)
svc := NewTenantService(repo, userRepo)
func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
mockUserRepo := new(MockUserRepoForTenant)
mockKeto := new(MockKetoSvcForTenant)
tenant, err := svc.RequestRegistration(
context.Background(),
"Tenant B",
"tenant-b",
"desc",
"tenant-b.com",
"admin@other.com",
)
assert.Error(t, err)
assert.ErrorContains(t, err, "admin email domain must match the tenant domain")
assert.Nil(t, tenant)
svc := NewTenantService(mockRepo, mockUserRepo)
svc.SetKetoService(mockKeto)
repo.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)
repo.AssertNotCalled(t, "AddDomain", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}
func TestTenantService_ApproveTenant_AssignsAdminRelationWhenUserExists(t *testing.T) {
repo := new(tenantServiceTenantRepoMock)
userRepo := new(tenantServiceUserRepoMock)
keto := new(MockKetoService)
svc := NewTenantService(repo, userRepo)
svc.SetKetoService(keto)
ctx := context.Background()
tenantID := "t1"
adminEmail := "admin@tenant.com"
userID := "user-uuid"
tenant := &domain.Tenant{
ID: "tenant-3",
Slug: "tenant-c",
Status: domain.TenantStatusPending,
Config: domain.JSONMap{"adminEmail": "admin@tenant-c.com"},
ID: tenantID,
Slug: "tenant-slug",
Config: domain.JSONMap{"adminEmail": adminEmail},
}
repo.On("FindByID", mock.Anything, "tenant-3").Return(tenant, nil).Once()
repo.On("Update", mock.Anything, mock.MatchedBy(func(updated *domain.Tenant) bool {
return updated.ID == "tenant-3" && updated.Status == domain.TenantStatusActive
})).Return(nil).Once()
userRepo.On("FindByEmail", mock.Anything, "admin@tenant-c.com").Return(&domain.User{
ID: "user-1",
Email: "admin@tenant-c.com",
}, nil).Once()
keto.On("CreateRelation", mock.Anything, "Tenant", "tenant-3", "admin", "User:user-1").Return(nil).Once()
mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil)
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
mockKeto.On("CreateRelation", ctx, "Tenant", tenantID, "admin", "User:"+userID).Return(nil)
err := svc.ApproveTenant(context.Background(), "tenant-3")
err := svc.ApproveTenant(ctx, tenantID)
assert.NoError(t, err)
repo.AssertExpectations(t)
userRepo.AssertExpectations(t)
keto.AssertExpectations(t)
}
func TestTenantService_ApproveTenant_DoesNotAssignWhenUserMissing(t *testing.T) {
repo := new(tenantServiceTenantRepoMock)
userRepo := new(tenantServiceUserRepoMock)
keto := new(MockKetoService)
svc := NewTenantService(repo, userRepo)
svc.SetKetoService(keto)
tenant := &domain.Tenant{
ID: "tenant-4",
Slug: "tenant-d",
Status: domain.TenantStatusPending,
Config: domain.JSONMap{"adminEmail": "admin@tenant-d.com"},
}
repo.On("FindByID", mock.Anything, "tenant-4").Return(tenant, nil).Once()
repo.On("Update", mock.Anything, mock.MatchedBy(func(updated *domain.Tenant) bool {
return updated.ID == "tenant-4" && updated.Status == domain.TenantStatusActive
})).Return(nil).Once()
userRepo.On("FindByEmail", mock.Anything, "admin@tenant-d.com").Return(nil, gorm.ErrRecordNotFound).Once()
err := svc.ApproveTenant(context.Background(), "tenant-4")
assert.NoError(t, err)
keto.AssertNotCalled(t, "CreateRelation", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
repo.AssertExpectations(t)
userRepo.AssertExpectations(t)
mockRepo.AssertExpectations(t)
mockUserRepo.AssertExpectations(t)
mockKeto.AssertExpectations(t)
}

View File

@@ -1,94 +1,56 @@
package utils
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidateSlug_Valid(t *testing.T) {
ok, msg := ValidateSlug("tenant-2026")
if !ok {
t.Fatalf("expected valid slug, got error: %s", msg)
}
}
func TestValidateSlug_ReservedKeywords(t *testing.T) {
cases := []string{
"stage",
"prod",
"metrics",
"prometheus",
"webmaster",
" Stage ",
}
for _, slug := range cases {
t.Run(slug, func(t *testing.T) {
ok, msg := ValidateSlug(slug)
if ok {
t.Fatalf("expected reserved slug to be rejected: %q", slug)
}
if msg != "slug is a reserved keyword" {
t.Fatalf("unexpected error message: %s", msg)
}
})
}
}
func TestValidateSlug_LengthRules(t *testing.T) {
tests := []struct {
name string
slug string
slug string
valid bool
}{
{name: "too short", slug: "ab"},
{name: "too long", slug: strings.Repeat("a", 33)},
{"my-tenant", true},
{"admin", false},
{"api", false},
{"static", false},
{"security", false},
{"billing", false},
{"ns", false},
{"mx", false},
{"webmaster", false},
{"status", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ok, msg := ValidateSlug(tc.slug)
if ok {
t.Fatalf("expected invalid length slug: %q", tc.slug)
}
if msg != "slug must be between 3 and 32 characters" {
t.Fatalf("unexpected error message: %s", msg)
}
for _, tt := range tests {
t.Run(tt.slug, func(t *testing.T) {
valid, msg := ValidateSlug(tt.slug)
assert.Equal(t, tt.valid, valid, "Slug: "+tt.slug+" - "+msg)
})
}
}
func TestValidateSlug_FormatRules(t *testing.T) {
func TestValidateSlug_Format(t *testing.T) {
tests := []struct {
name string
slug string
wantMsg string
slug string
valid bool
}{
{
name: "invalid character",
slug: "tenant_name",
wantMsg: "slug can only contain lowercase letters, numbers, and hyphens",
},
{
name: "leading hyphen",
slug: "-tenant",
wantMsg: "slug cannot start or end with a hyphen",
},
{
name: "trailing hyphen",
slug: "tenant-",
wantMsg: "slug cannot start or end with a hyphen",
},
{"abc", true},
{"a-b-c", true},
{"123", true},
{"ab", false}, // Too short
{"-abc", false}, // Starts with hyphen
{"abc-", false}, // Ends with hyphen
{"Abc", true}, // Case insensitive check (converted to lower)
{"invalid_slug", false}, // Contains underscore
{"too-long-slug-name-that-exceeds-thirty-two-chars", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ok, msg := ValidateSlug(tc.slug)
if ok {
t.Fatalf("expected invalid slug: %q", tc.slug)
}
if msg != tc.wantMsg {
t.Fatalf("unexpected error message: %s", msg)
}
for _, tt := range tests {
t.Run(tt.slug, func(t *testing.T) {
valid, _ := ValidateSlug(tt.slug)
assert.Equal(t, tt.valid, valid)
})
}
}

View File

@@ -211,10 +211,10 @@ services:
hydra create oauth2-client \
--endpoint http://hydra:4445 \
--id adminfront \
--secret admin-secret \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}
hydra create oauth2-client \
@@ -224,7 +224,7 @@ services:
--response-type code \
--scope openid,offline_access,profile,email \
--token-endpoint-auth-method none \
--redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}
--redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}
hydra create oauth2-client \
--endpoint http://hydra:4445 \

View File

@@ -15,7 +15,7 @@ export const router = createBrowserRouter(
element: <LoginPage />,
},
{
path: "/callback",
path: "/auth/callback",
element: <AuthCallbackPage />,
},
{

View File

@@ -4,7 +4,7 @@ import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
redirect_uri: `${window.location.origin}/callback`,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin,