1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Lectom C Han
2026-02-20 10:09:54 +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 (콤마 구분) # 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

View File

@@ -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",

View File

@@ -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",

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" 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>
); );
} }

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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" />
... ...

View File

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

View File

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

View File

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

View File

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

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 { 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>,
); );

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

View File

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

View File

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

View File

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

View File

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