forked from baron/baron-sso
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -110,7 +110,7 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
|
|||||||
|
|
||||||
# OIDC 클라이언트 callback (콤마 구분)
|
# OIDC 클라이언트 callback (콤마 구분)
|
||||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/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 allowed_return_urls 확장 목록 (콤마 구분, 선택)
|
||||||
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
|
||||||
@@ -134,9 +134,9 @@ CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
|
|||||||
CSRF_COOKIE_SECRET=localcsrf123
|
CSRF_COOKIE_SECRET=localcsrf123
|
||||||
|
|
||||||
# AdminFront OIDC 설정
|
# 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 설정
|
# DevFront OIDC 설정
|
||||||
VITE_OIDC_CLIENT_ID=devfront
|
VITE_OIDC_CLIENT_ID=devfront
|
||||||
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
|
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
|
||||||
36
adminfront/package-lock.json
generated
36
adminfront/package-lock.json
generated
@@ -20,9 +20,11 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"oidc-client-ts": "^3.4.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-oidc-context": "^3.3.0",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
@@ -3163,6 +3165,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
@@ -3605,6 +3616,18 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
@@ -3926,6 +3949,19 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -26,9 +26,11 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"oidc-client-ts": "^3.4.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-oidc-context": "^3.3.0",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|||||||
@@ -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"
|
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", "언어")}
|
aria-label={t("ui.common.language", "언어")}
|
||||||
>
|
>
|
||||||
<option value="ko">
|
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
|
||||||
{t("ui.common.language_ko", "한국어")}
|
<option value="en">{t("ui.common.language_en", "English")}</option>
|
||||||
</option>
|
|
||||||
<option value="en">
|
|
||||||
{t("ui.common.language_en", "English")}
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
@@ -40,6 +41,7 @@ const navItems = [
|
|||||||
{ 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 auth = useAuth();
|
||||||
const navigate = useNavigate();
|
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");
|
||||||
@@ -51,16 +53,16 @@ function AppLayout() {
|
|||||||
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
|
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
|
||||||
) {
|
) {
|
||||||
window.localStorage.removeItem("admin_session");
|
window.localStorage.removeItem("admin_session");
|
||||||
|
auth.removeUser();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const session = window.localStorage.getItem("admin_session");
|
if (!auth.isLoading && !auth.isAuthenticated) {
|
||||||
if (!session) {
|
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [auth.isLoading, auth.isAuthenticated, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -77,6 +79,14 @@ function AppLayout() {
|
|||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
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 (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ const badgeVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ const buttonVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import * as React from "react"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import { X } from "lucide-react";
|
||||||
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<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
@@ -20,12 +20,12 @@ const DialogOverlay = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -48,8 +48,8 @@ const DialogContent = React.forwardRef<
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -58,12 +58,12 @@ const DialogHeader = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -72,12 +72,12 @@ const DialogFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
@@ -87,12 +87,12 @@ const DialogTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
@@ -103,8 +103,8 @@ const DialogDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -117,4 +117,4 @@ export {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
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>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import * as React from "react"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
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<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
))
|
));
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
@@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
))
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
@@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
))
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName =
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
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",
|
"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" &&
|
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",
|
"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}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
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}
|
{children}
|
||||||
@@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
))
|
));
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
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)}
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
|
|||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
));
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
@@ -141,8 +141,8 @@ const SelectSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
@@ -155,4 +155,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "../../lib/utils";
|
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>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
import { ShieldHalf } from "lucide-react";
|
import { ShieldHalf } from "lucide-react";
|
||||||
import { useEffect } from "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() {
|
function AuthCallbackPage() {
|
||||||
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = searchParams.get("token");
|
if (auth.isAuthenticated) {
|
||||||
if (token) {
|
// Save token to localStorage for existing API clients that might still use it
|
||||||
window.localStorage.setItem("admin_session", token);
|
const user = auth.user;
|
||||||
|
if (user?.access_token) {
|
||||||
// 만약 팝업창에서 실행 중이라면 부모 창에 알리고 닫기
|
window.localStorage.setItem("admin_session", user.access_token);
|
||||||
if (window.opener) {
|
|
||||||
window.opener.postMessage({ type: "LOGIN_SUCCESS", token }, "*");
|
|
||||||
window.close();
|
|
||||||
} else {
|
|
||||||
// 일반 리다이렉트 방식인 경우 홈으로 이동
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
}
|
}
|
||||||
} else {
|
navigate("/", { replace: true });
|
||||||
console.error("No token found in callback URL");
|
} else if (auth.error) {
|
||||||
|
console.error("Auth Error:", auth.error);
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
}
|
}
|
||||||
}, [navigate, searchParams]);
|
}, [auth.isAuthenticated, auth.error, navigate, auth.user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,53 +10,11 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const auth = useAuth();
|
||||||
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 handleSSOLogin = () => {
|
||||||
const userfrontUrl = import.meta.env.USERFRONT_URL || "https://sso.hmac.kr";
|
// OIDC client-side authentication flow started here
|
||||||
const callbackUrl = `${window.location.origin}/auth/callback`;
|
auth.signinRedirect();
|
||||||
|
|
||||||
// 항상 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 (
|
return (
|
||||||
@@ -89,9 +46,9 @@ function LoginPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSSOLogin}
|
onClick={handleSSOLogin}
|
||||||
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
|
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" />
|
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
로그인 진행 중...
|
로그인 진행 중...
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Building2, Plus, Users } from "lucide-react";
|
import { Building2, Plus, Users } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -18,8 +19,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { fetchTenants, fetchGroups } from "../../../lib/adminApi";
|
import { fetchGroups, fetchTenants } from "../../../lib/adminApi";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export default function GlobalUserGroupListPage() {
|
export default function GlobalUserGroupListPage() {
|
||||||
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||||
@@ -27,7 +27,8 @@ export default function GlobalUserGroupListPage() {
|
|||||||
queryFn: () => fetchTenants(100, 0),
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -35,7 +36,8 @@ export default function GlobalUserGroupListPage() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
|
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
모든 테넌트의 유저 그룹을 관리합니다. 권한 상속의 주체가 되는 그룹을 설정하세요.
|
모든 테넌트의 유저 그룹을 관리합니다. 권한 상속의 주체가 되는 그룹을
|
||||||
|
설정하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -62,7 +64,9 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
|
|||||||
<CardTitle className="text-xl flex items-center gap-2">
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
<Building2 size={20} className="text-muted-foreground" />
|
<Building2 size={20} className="text-muted-foreground" />
|
||||||
{tenant.name}
|
{tenant.name}
|
||||||
<Badge variant="outline" className="ml-2">{tenant.slug}</Badge>
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{tenant.slug}
|
||||||
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
이 테넌트에 정의된 유저 그룹 목록입니다.
|
이 테넌트에 정의된 유저 그룹 목록입니다.
|
||||||
@@ -88,11 +92,16 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center">Loading...</TableCell>
|
<TableCell colSpan={4} className="text-center">
|
||||||
|
Loading...
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : groups?.length === 0 ? (
|
) : groups?.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-4">
|
<TableCell
|
||||||
|
colSpan={4}
|
||||||
|
className="text-center text-muted-foreground py-4"
|
||||||
|
>
|
||||||
등록된 유저 그룹이 없습니다.
|
등록된 유저 그룹이 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -102,7 +111,10 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
|
|||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users size={14} className="text-primary" />
|
<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}
|
{group.name}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +123,11 @@ function TenantGroupCard({ tenant }: { tenant: any }) {
|
|||||||
<TableCell>{group.members?.length || 0} 명</TableCell>
|
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<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>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export function UserGroupDetailPage() {
|
|||||||
const [selectedRelation, setSelectedRelation] = useState("view");
|
const [selectedRelation, setSelectedRelation] = useState("view");
|
||||||
|
|
||||||
// Fetch specific group details
|
// Fetch specific group details
|
||||||
const { data: currentGroup, isLoading: isGroupLoading, error } = useQuery({
|
const {
|
||||||
|
data: currentGroup,
|
||||||
|
isLoading: isGroupLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ["user-group-detail", id],
|
queryKey: ["user-group-detail", id],
|
||||||
queryFn: () => fetchGroup(tenantId!, id!),
|
queryFn: () => fetchGroup(tenantId!, id!),
|
||||||
enabled: !!id && !!tenantId,
|
enabled: !!id && !!tenantId,
|
||||||
@@ -112,12 +116,7 @@ export function UserGroupDetailPage() {
|
|||||||
|
|
||||||
const assignRoleMutation = useMutation({
|
const assignRoleMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
assignGroupRole(
|
assignGroupRole(tenantId!, id!, selectedTargetTenantId, selectedRelation),
|
||||||
tenantId!,
|
|
||||||
id!,
|
|
||||||
selectedTargetTenantId,
|
|
||||||
selectedRelation,
|
|
||||||
),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
|
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
|
||||||
setIsAddRoleOpen(false);
|
setIsAddRoleOpen(false);
|
||||||
@@ -137,29 +136,50 @@ export function UserGroupDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isGroupLoading) return (
|
if (isGroupLoading)
|
||||||
<div className="flex items-center justify-center p-12">
|
return (
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<div className="flex items-center justify-center p-12">
|
||||||
<span className="ml-3 text-muted-foreground">Loading group details...</span>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
</div>
|
<span className="ml-3 text-muted-foreground">
|
||||||
);
|
Loading group details...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (error || !currentGroup) return (
|
if (error || !currentGroup)
|
||||||
<div className="p-8 text-center space-y-4">
|
return (
|
||||||
<h3 className="text-xl font-semibold text-destructive">Could not load group</h3>
|
<div className="p-8 text-center space-y-4">
|
||||||
<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">
|
<h3 className="text-xl font-semibold text-destructive">
|
||||||
<p>Error: {(error as any)?.response?.data?.error || (error as any)?.message || "Not found"}</p>
|
Could not load group
|
||||||
<p className="mt-2 text-red-500 opacity-70">Path: /admin/tenants/{tenantId}/user-groups/{id}</p>
|
</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>
|
</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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -187,8 +207,8 @@ export function UserGroupDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Badge variant="outline">User Group</Badge>
|
<Badge variant="outline">User Group</Badge>
|
||||||
<Badge variant="muted">Tenant: {tenantId?.split('-')[0]}...</Badge>
|
<Badge variant="muted">Tenant: {tenantId?.split("-")[0]}...</Badge>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -217,15 +237,18 @@ export function UserGroupDetailPage() {
|
|||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Search User</Label>
|
<Label>Search User</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by email or name..."
|
placeholder="Search by email or name..."
|
||||||
value={searchUser}
|
value={searchUser}
|
||||||
onChange={(e) => setSearchUser(e.target.value)}
|
onChange={(e) => setSearchUser(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Select User</Label>
|
<Label>Select User</Label>
|
||||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
<Select
|
||||||
|
value={selectedUserId}
|
||||||
|
onValueChange={setSelectedUserId}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choose a user" />
|
<SelectValue placeholder="Choose a user" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -240,10 +263,13 @@ export function UserGroupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsAddMemberOpen(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsAddMemberOpen(false)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addMemberMutation.mutate(selectedUserId)}
|
onClick={() => addMemberMutation.mutate(selectedUserId)}
|
||||||
disabled={!selectedUserId || addMemberMutation.isPending}
|
disabled={!selectedUserId || addMemberMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -264,7 +290,10 @@ export function UserGroupDetailPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{!currentGroup.members || currentGroup.members.length === 0 ? (
|
{!currentGroup.members || currentGroup.members.length === 0 ? (
|
||||||
<TableRow>
|
<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.
|
No members in this group.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -274,13 +303,15 @@ export function UserGroupDetailPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{member.name}</p>
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => removeMemberMutation.mutate(member.id)}
|
onClick={() => removeMemberMutation.mutate(member.id)}
|
||||||
>
|
>
|
||||||
@@ -300,7 +331,9 @@ export function UserGroupDetailPage() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Permissions</CardTitle>
|
<CardTitle>Permissions</CardTitle>
|
||||||
<CardDescription>Tenant roles assigned to this group.</CardDescription>
|
<CardDescription>
|
||||||
|
Tenant roles assigned to this group.
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
|
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -313,13 +346,17 @@ export function UserGroupDetailPage() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Assign Tenant Role</DialogTitle>
|
<DialogTitle>Assign Tenant Role</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Target Tenant</Label>
|
<Label>Target Tenant</Label>
|
||||||
<Select value={selectedTargetTenantId} onValueChange={setSelectedTargetTenantId}>
|
<Select
|
||||||
|
value={selectedTargetTenantId}
|
||||||
|
onValueChange={setSelectedTargetTenantId}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select target tenant" />
|
<SelectValue placeholder="Select target tenant" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -334,25 +371,37 @@ export function UserGroupDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Role (Relation)</Label>
|
<Label>Role (Relation)</Label>
|
||||||
<Select value={selectedRelation} onValueChange={setSelectedRelation}>
|
<Select
|
||||||
|
value={selectedRelation}
|
||||||
|
onValueChange={setSelectedRelation}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="view">View (Read-only)</SelectItem>
|
<SelectItem value="view">View (Read-only)</SelectItem>
|
||||||
<SelectItem value="manage">Manage (Read/Write)</SelectItem>
|
<SelectItem value="manage">
|
||||||
<SelectItem value="admins">Admin (Full Control)</SelectItem>
|
Manage (Read/Write)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="admins">
|
||||||
|
Admin (Full Control)
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsAddRoleOpen(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsAddRoleOpen(false)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => assignRoleMutation.mutate()}
|
onClick={() => assignRoleMutation.mutate()}
|
||||||
disabled={!selectedTargetTenantId || assignRoleMutation.isPending}
|
disabled={
|
||||||
|
!selectedTargetTenantId || assignRoleMutation.isPending
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Assign
|
Assign
|
||||||
</Button>
|
</Button>
|
||||||
@@ -371,10 +420,17 @@ export function UserGroupDetailPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isRolesLoading ? (
|
{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 ? (
|
) : !groupRoles || groupRoles.length === 0 ? (
|
||||||
<TableRow>
|
<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.
|
No roles assigned.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -382,17 +438,26 @@ export function UserGroupDetailPage() {
|
|||||||
groupRoles.map((role, idx) => (
|
groupRoles.map((role, idx) => (
|
||||||
<TableRow key={`${role.tenantId}-${role.relation}-${idx}`}>
|
<TableRow key={`${role.tenantId}-${role.relation}-${idx}`}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{role.tenantName || role.tenantId}</div>
|
<div className="font-medium">
|
||||||
|
{role.tenantName || role.tenantId}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className="capitalize">{role.relation}</Badge>
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{role.relation}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => removeRoleMutation.mutate({ targetTenantId: role.tenantId, relation: role.relation })}
|
onClick={() =>
|
||||||
|
removeRoleMutation.mutate({
|
||||||
|
targetTenantId: role.tenantId,
|
||||||
|
relation: role.relation,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -54,8 +54,7 @@
|
|||||||
|
|
||||||
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:
|
background-image: radial-gradient(
|
||||||
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%
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ export async function createGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGroup(tenantId: string, groupId: string) {
|
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(
|
export async function addGroupMember(
|
||||||
|
|||||||
20
adminfront/src/lib/auth.ts
Normal file
20
adminfront/src/lib/auth.ts
Normal 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 || "",
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { AuthProvider } from "react-oidc-context";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
import { queryClient } from "./app/queryClient";
|
import { queryClient } from "./app/queryClient";
|
||||||
import { router } from "./app/routes";
|
import { router } from "./app/routes";
|
||||||
|
import { oidcConfig } from "./lib/auth";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
@@ -14,8 +16,10 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AuthProvider {...oidcConfig}>
|
||||||
<RouterProvider router={router} />
|
<QueryClientProvider client={queryClient}>
|
||||||
</QueryClientProvider>
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
148
backend/internal/handler/auth_handler_signup_test.go
Normal file
148
backend/internal/handler/auth_handler_signup_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,24 +7,23 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type tenantServiceTenantRepoMock struct {
|
// --- Local Mocks to avoid collisions ---
|
||||||
|
|
||||||
|
type MockTenantRepoForSvc struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceTenantRepoMock) Create(ctx context.Context, tenant *domain.Tenant) error {
|
func (m *MockTenantRepoForSvc) Create(ctx context.Context, tenant *domain.Tenant) error {
|
||||||
args := m.Called(ctx, tenant)
|
return m.Called(ctx, tenant).Error(0)
|
||||||
return args.Error(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceTenantRepoMock) Update(ctx context.Context, tenant *domain.Tenant) error {
|
func (m *MockTenantRepoForSvc) Update(ctx context.Context, tenant *domain.Tenant) error {
|
||||||
args := m.Called(ctx, tenant)
|
return m.Called(ctx, tenant).Error(0)
|
||||||
return args.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)
|
args := m.Called(ctx, id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
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)
|
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)
|
args := m.Called(ctx, slug)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
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)
|
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceTenantRepoMock) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
|
func (m *MockTenantRepoForSvc) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
|
||||||
args := m.Called(ctx, name)
|
return nil, nil
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
args := m.Called(ctx, domainName)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
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)
|
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceTenantRepoMock) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
|
||||||
args := m.Called(ctx, ids)
|
return nil, nil
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceTenantRepoMock) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
|
func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
|
||||||
args := m.Called(ctx, tenantID, domainName, verified)
|
return m.Called(ctx, tenantID, domainName, verified).Error(0)
|
||||||
return args.Error(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantServiceUserRepoMock struct {
|
type MockKetoSvcForTenant struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceUserRepoMock) Create(ctx context.Context, user *domain.User) error {
|
func (m *MockKetoSvcForTenant) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
args := m.Called(ctx, user)
|
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||||
return args.Error(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceUserRepoMock) Update(ctx context.Context, user *domain.User) error {
|
func (m *MockKetoSvcForTenant) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
args := m.Called(ctx, user)
|
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||||
return args.Error(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceUserRepoMock) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
func (m *MockKetoSvcForTenant) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
|
||||||
args := m.Called(ctx, email)
|
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 {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
}
|
}
|
||||||
return args.Get(0).(*domain.User), args.Error(1)
|
return args.Get(0).(*domain.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceUserRepoMock) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
||||||
args := m.Called(ctx, id)
|
return nil, nil
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).(*domain.User), args.Error(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceUserRepoMock) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
func (m *MockUserRepoForTenant) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
||||||
args := m.Called(ctx, ids)
|
return nil, nil
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]domain.User), args.Error(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceUserRepoMock) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||||
args := m.Called(ctx, tenantID)
|
return nil, nil
|
||||||
if args.Get(0) == nil {
|
|
||||||
return nil, args.Error(1)
|
|
||||||
}
|
|
||||||
return args.Get(0).([]domain.User), args.Error(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tenantServiceUserRepoMock) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
||||||
args := m.Called(ctx, offset, limit, search)
|
return nil, 0, nil
|
||||||
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 TestTenantService_RegisterTenant_AddsDomainsAsVerified(t *testing.T) {
|
// --- Tests ---
|
||||||
repo := new(tenantServiceTenantRepoMock)
|
|
||||||
userRepo := new(tenantServiceUserRepoMock)
|
|
||||||
svc := NewTenantService(repo, userRepo)
|
|
||||||
|
|
||||||
repo.On("FindBySlug", mock.Anything, "tenant-a").Return(nil, gorm.ErrRecordNotFound).Once()
|
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||||
repo.On("Create", mock.Anything, mock.MatchedBy(func(tenant *domain.Tenant) bool {
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
return tenant.Name == "Tenant A" &&
|
svc := NewTenantService(mockRepo, nil)
|
||||||
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()
|
|
||||||
|
|
||||||
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.NoError(t, err)
|
||||||
assert.NotNil(t, tenant)
|
assert.NotNil(t, tenant)
|
||||||
assert.Equal(t, "tenant-1", tenant.ID)
|
assert.Equal(t, "t1", tenant.ID)
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
repo.AssertExpectations(t)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantService_RequestRegistration_AddsDomainAsUnverified(t *testing.T) {
|
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
|
||||||
repo := new(tenantServiceTenantRepoMock)
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
userRepo := new(tenantServiceUserRepoMock)
|
svc := NewTenantService(mockRepo, nil)
|
||||||
svc := NewTenantService(repo, userRepo)
|
|
||||||
|
|
||||||
repo.On("Create", mock.Anything, mock.MatchedBy(func(tenant *domain.Tenant) bool {
|
ctx := context.Background()
|
||||||
return tenant.Name == "Tenant B" &&
|
name := "Public Tenant"
|
||||||
tenant.Slug == "tenant-b" &&
|
slug := "public-tenant"
|
||||||
tenant.Status == domain.TenantStatusPending &&
|
domainName := "public.com"
|
||||||
tenant.Config["adminEmail"] == "admin@tenant-b.com"
|
adminEmail := "admin@public.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()
|
|
||||||
|
|
||||||
tenant, err := svc.RequestRegistration(
|
mockRepo.On("Create", ctx, mock.MatchedBy(func(tenant *domain.Tenant) bool {
|
||||||
context.Background(),
|
return tenant.Status == domain.TenantStatusPending
|
||||||
"Tenant B",
|
})).Return(nil)
|
||||||
"tenant-b",
|
mockRepo.On("AddDomain", ctx, mock.Anything, domainName, false).Return(nil)
|
||||||
"desc",
|
|
||||||
"tenant-b.com",
|
tenant, err := svc.RequestRegistration(ctx, name, slug, "", domainName, adminEmail)
|
||||||
"admin@tenant-b.com",
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, tenant)
|
assert.NotNil(t, tenant)
|
||||||
assert.Equal(t, "tenant-2", tenant.ID)
|
mockRepo.AssertExpectations(t)
|
||||||
assert.Equal(t, domain.TenantStatusPending, tenant.Status)
|
|
||||||
|
|
||||||
repo.AssertExpectations(t)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTenantService_RequestRegistration_RejectsDomainMismatch(t *testing.T) {
|
func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
||||||
repo := new(tenantServiceTenantRepoMock)
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
userRepo := new(tenantServiceUserRepoMock)
|
mockUserRepo := new(MockUserRepoForTenant)
|
||||||
svc := NewTenantService(repo, userRepo)
|
mockKeto := new(MockKetoSvcForTenant)
|
||||||
|
|
||||||
tenant, err := svc.RequestRegistration(
|
svc := NewTenantService(mockRepo, mockUserRepo)
|
||||||
context.Background(),
|
svc.SetKetoService(mockKeto)
|
||||||
"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)
|
|
||||||
|
|
||||||
repo.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)
|
ctx := context.Background()
|
||||||
repo.AssertNotCalled(t, "AddDomain", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
tenantID := "t1"
|
||||||
}
|
adminEmail := "admin@tenant.com"
|
||||||
|
userID := "user-uuid"
|
||||||
func TestTenantService_ApproveTenant_AssignsAdminRelationWhenUserExists(t *testing.T) {
|
|
||||||
repo := new(tenantServiceTenantRepoMock)
|
|
||||||
userRepo := new(tenantServiceUserRepoMock)
|
|
||||||
keto := new(MockKetoService)
|
|
||||||
svc := NewTenantService(repo, userRepo)
|
|
||||||
svc.SetKetoService(keto)
|
|
||||||
|
|
||||||
tenant := &domain.Tenant{
|
tenant := &domain.Tenant{
|
||||||
ID: "tenant-3",
|
ID: tenantID,
|
||||||
Slug: "tenant-c",
|
Slug: "tenant-slug",
|
||||||
Status: domain.TenantStatusPending,
|
Config: domain.JSONMap{"adminEmail": adminEmail},
|
||||||
Config: domain.JSONMap{"adminEmail": "admin@tenant-c.com"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.On("FindByID", mock.Anything, "tenant-3").Return(tenant, nil).Once()
|
mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil)
|
||||||
repo.On("Update", mock.Anything, mock.MatchedBy(func(updated *domain.Tenant) bool {
|
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
||||||
return updated.ID == "tenant-3" && updated.Status == domain.TenantStatusActive
|
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
|
||||||
})).Return(nil).Once()
|
mockKeto.On("CreateRelation", ctx, "Tenant", tenantID, "admin", "User:"+userID).Return(nil)
|
||||||
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()
|
|
||||||
|
|
||||||
err := svc.ApproveTenant(context.Background(), "tenant-3")
|
err := svc.ApproveTenant(ctx, tenantID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
repo.AssertExpectations(t)
|
mockUserRepo.AssertExpectations(t)
|
||||||
userRepo.AssertExpectations(t)
|
mockKeto.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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,56 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"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) {
|
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 {
|
tests := []struct {
|
||||||
name string
|
slug string
|
||||||
slug string
|
valid bool
|
||||||
}{
|
}{
|
||||||
{name: "too short", slug: "ab"},
|
{"my-tenant", true},
|
||||||
{name: "too long", slug: strings.Repeat("a", 33)},
|
{"admin", false},
|
||||||
|
{"api", false},
|
||||||
|
{"static", false},
|
||||||
|
{"security", false},
|
||||||
|
{"billing", false},
|
||||||
|
{"ns", false},
|
||||||
|
{"mx", false},
|
||||||
|
{"webmaster", false},
|
||||||
|
{"status", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tt.slug, func(t *testing.T) {
|
||||||
ok, msg := ValidateSlug(tc.slug)
|
valid, msg := ValidateSlug(tt.slug)
|
||||||
if ok {
|
assert.Equal(t, tt.valid, valid, "Slug: "+tt.slug+" - "+msg)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateSlug_FormatRules(t *testing.T) {
|
func TestValidateSlug_Format(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
slug string
|
||||||
slug string
|
valid bool
|
||||||
wantMsg string
|
|
||||||
}{
|
}{
|
||||||
{
|
{"abc", true},
|
||||||
name: "invalid character",
|
{"a-b-c", true},
|
||||||
slug: "tenant_name",
|
{"123", true},
|
||||||
wantMsg: "slug can only contain lowercase letters, numbers, and hyphens",
|
{"ab", false}, // Too short
|
||||||
},
|
{"-abc", false}, // Starts with hyphen
|
||||||
{
|
{"abc-", false}, // Ends with hyphen
|
||||||
name: "leading hyphen",
|
{"Abc", true}, // Case insensitive check (converted to lower)
|
||||||
slug: "-tenant",
|
{"invalid_slug", false}, // Contains underscore
|
||||||
wantMsg: "slug cannot start or end with a hyphen",
|
{"too-long-slug-name-that-exceeds-thirty-two-chars", false},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trailing hyphen",
|
|
||||||
slug: "tenant-",
|
|
||||||
wantMsg: "slug cannot start or end with a hyphen",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tt.slug, func(t *testing.T) {
|
||||||
ok, msg := ValidateSlug(tc.slug)
|
valid, _ := ValidateSlug(tt.slug)
|
||||||
if ok {
|
assert.Equal(t, tt.valid, valid)
|
||||||
t.Fatalf("expected invalid slug: %q", tc.slug)
|
|
||||||
}
|
|
||||||
if msg != tc.wantMsg {
|
|
||||||
t.Fatalf("unexpected error message: %s", msg)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,10 +211,10 @@ services:
|
|||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
--endpoint http://hydra:4445 \
|
--endpoint http://hydra:4445 \
|
||||||
--id adminfront \
|
--id adminfront \
|
||||||
--secret admin-secret \
|
|
||||||
--grant-type authorization_code,refresh_token \
|
--grant-type authorization_code,refresh_token \
|
||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
|
--token-endpoint-auth-method none \
|
||||||
--redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}
|
--redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}
|
||||||
|
|
||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
@@ -224,7 +224,7 @@ services:
|
|||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
--token-endpoint-auth-method none \
|
--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 \
|
hydra create oauth2-client \
|
||||||
--endpoint http://hydra:4445 \
|
--endpoint http://hydra:4445 \
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const router = createBrowserRouter(
|
|||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/callback",
|
path: "/auth/callback",
|
||||||
element: <AuthCallbackPage />,
|
element: <AuthCallbackPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AuthProviderProps } from "react-oidc-context";
|
|||||||
export const oidcConfig: AuthProviderProps = {
|
export const oidcConfig: AuthProviderProps = {
|
||||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
||||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
|
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",
|
response_type: "code",
|
||||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||||
post_logout_redirect_uri: window.location.origin,
|
post_logout_redirect_uri: window.location.origin,
|
||||||
|
|||||||
Reference in New Issue
Block a user