1
0
forked from baron/baron-sso

feat: add schema tab access control and user password generator

This commit is contained in:
2026-03-19 13:40:50 +09:00
parent 046acd3ca6
commit 3e335eb9cf
3 changed files with 139 additions and 21 deletions

View File

@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchTenant } from "../../../lib/adminApi";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantDetailPage() {
@@ -16,6 +16,14 @@ function TenantDetailPage() {
enabled: tenantId.length > 0,
});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const isFederationTab = location.pathname.includes("/federation");
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
@@ -98,16 +106,18 @@ function TenantDetailPage() {
>
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
{canAccessSchema && (
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -14,7 +14,7 @@ import {
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaFieldType = "text" | "number" | "boolean" | "date";
@@ -40,6 +40,38 @@ export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const canAccess =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
if (isProfileLoading) {
return (
<div className="p-8 text-center animate-pulse text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
</div>
);
}
if (!canAccess) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<p className="text-muted-foreground">
{t(
"msg.admin.tenants.schema.forbidden_desc",
"사용자 스키마 설정은 관리자만 접근할 수 있습니다.",
)}
</p>
</div>
);
}
if (!tenantId) {
return (
<div className="p-8 text-center text-muted-foreground">

View File

@@ -4,6 +4,10 @@ import {
ArrowLeft,
BadgeCheck,
Building2,
Copy,
Dices,
Eye,
EyeOff,
Loader2,
Save,
Users,
@@ -15,6 +19,7 @@ import {
useForm,
} from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -36,6 +41,19 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
// Utility for secure password generation
function generateSecurePassword(length = 16) {
const charset =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
let retVal = "";
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
retVal += charset.charAt(values[i] % charset.length);
}
return retVal;
}
type UserSchemaField = {
key: string;
label?: string;
@@ -148,6 +166,7 @@ function UserDetailPage() {
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const [showPassword, setShowPassword] = React.useState(false);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -175,6 +194,7 @@ function UserDetailPage() {
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm<UserFormValues>({
defaultValues: {
@@ -194,6 +214,28 @@ function UserDetailPage() {
const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const handleGeneratePassword = () => {
const newPass = generateSecurePassword();
setValue("password", newPass);
setShowPassword(true);
toast.success(
t(
"msg.admin.users.detail.password_generated",
"안전한 비밀번호가 생성되었습니다.",
),
);
};
const handleCopyPassword = () => {
const pass = watch("password");
if (pass) {
navigator.clipboard.writeText(pass);
toast.success(
t("msg.common.copied_to_clipboard", "클립보드에 복사되었습니다."),
);
}
};
React.useEffect(() => {
if (user) {
reset({
@@ -556,15 +598,49 @@ function UserDetailPage() {
"비밀번호 변경",
)}
</Label>
<Input
id="password"
type="password"
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
{...register("password")}
/>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t(
"ui.admin.users.detail.security.password_placeholder",
"변경할 경우에만 입력",
)}
className="font-mono"
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Button
type="button"
variant="outline"
onClick={handleGeneratePassword}
title={t(
"ui.admin.users.detail.generate_password",
"자동 생성",
)}
>
<Dices size={16} className="mr-2" />
{t("ui.common.generate", "생성")}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyPassword}
disabled={!watch("password")}
title={t("ui.common.copy", "복사")}
>
<Copy size={16} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.security.password_hint",