forked from baron/baron-sso
feat: add schema tab access control and user password generator
This commit is contained in:
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { fetchTenant } from "../../../lib/adminApi";
|
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
function TenantDetailPage() {
|
function TenantDetailPage() {
|
||||||
@@ -16,6 +16,14 @@ function TenantDetailPage() {
|
|||||||
enabled: tenantId.length > 0,
|
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 isFederationTab = location.pathname.includes("/federation");
|
||||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||||
const isOrganizationTab = location.pathname.includes("/organization");
|
const isOrganizationTab = location.pathname.includes("/organization");
|
||||||
@@ -98,16 +106,18 @@ function TenantDetailPage() {
|
|||||||
>
|
>
|
||||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{canAccessSchema && (
|
||||||
to={`/tenants/${tenantId}/schema`}
|
<Link
|
||||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
to={`/tenants/${tenantId}/schema`}
|
||||||
location.pathname.includes("/schema")
|
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||||
? "text-primary border-b-2 border-primary"
|
location.pathname.includes("/schema")
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-primary border-b-2 border-primary"
|
||||||
}`}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
>
|
}`}
|
||||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
>
|
||||||
</Link>
|
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outlet for nested routes */}
|
{/* Outlet for nested routes */}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
|
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
type SchemaFieldType = "text" | "number" | "boolean" | "date";
|
type SchemaFieldType = "text" | "number" | "boolean" | "date";
|
||||||
@@ -40,6 +40,38 @@ export function TenantSchemaPage() {
|
|||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
const queryClient = useQueryClient();
|
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) {
|
if (!tenantId) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Building2,
|
Building2,
|
||||||
|
Copy,
|
||||||
|
Dices,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Loader2,
|
Loader2,
|
||||||
Save,
|
Save,
|
||||||
Users,
|
Users,
|
||||||
@@ -15,6 +19,7 @@ import {
|
|||||||
useForm,
|
useForm,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -36,6 +41,19 @@ import {
|
|||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
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 = {
|
type UserSchemaField = {
|
||||||
key: string;
|
key: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -148,6 +166,7 @@ function UserDetailPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
@@ -175,6 +194,7 @@ function UserDetailPage() {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<UserFormValues>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -194,6 +214,28 @@ function UserDetailPage() {
|
|||||||
const isAdmin =
|
const isAdmin =
|
||||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
reset({
|
reset({
|
||||||
@@ -556,15 +598,49 @@ function UserDetailPage() {
|
|||||||
"비밀번호 변경",
|
"비밀번호 변경",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="password"
|
<div className="relative flex-1">
|
||||||
type="password"
|
<Input
|
||||||
placeholder={t(
|
id="password"
|
||||||
"ui.admin.users.detail.security.password_placeholder",
|
type={showPassword ? "text" : "password"}
|
||||||
"변경할 경우에만 입력",
|
placeholder={t(
|
||||||
)}
|
"ui.admin.users.detail.security.password_placeholder",
|
||||||
{...register("password")}
|
"변경할 경우에만 입력",
|
||||||
/>
|
)}
|
||||||
|
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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.security.password_hint",
|
"msg.admin.users.detail.security.password_hint",
|
||||||
|
|||||||
Reference in New Issue
Block a user