feat: implement storage bucket management with configuration pages and API integration
This commit is contained in:
parent
b0fe466a3d
commit
69a85a1c9b
@ -109,6 +109,7 @@ const superAdminSystemMenu: MenuItem[] = [
|
|||||||
{ label: "SMTP Config", path: "/settings/smtp" },
|
{ label: "SMTP Config", path: "/settings/smtp" },
|
||||||
{ label: "Failed Emails", path: "/settings/failed-emails" },
|
{ label: "Failed Emails", path: "/settings/failed-emails" },
|
||||||
{ label: "AI Fallback Monitoring",path: "/settings/ai-fallbacks" },
|
{ label: "AI Fallback Monitoring",path: "/settings/ai-fallbacks" },
|
||||||
|
{ label: "Storage Buckets", path: "/settings/storage-buckets" },
|
||||||
],
|
],
|
||||||
requiredPermission: { resource: "settings", action: "read" },
|
requiredPermission: { resource: "settings", action: "read" },
|
||||||
},
|
},
|
||||||
@ -301,6 +302,10 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
label: "Failed Emails",
|
label: "Failed Emails",
|
||||||
path: "/tenant/settings/failed-emails",
|
path: "/tenant/settings/failed-emails",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Storage Config",
|
||||||
|
path: "/tenant/settings/storage",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
requiredPermission: { resource: "tenants" },
|
requiredPermission: { resource: "tenants" },
|
||||||
},
|
},
|
||||||
|
|||||||
299
src/pages/superadmin/StorageBucketDetail.tsx
Normal file
299
src/pages/superadmin/StorageBucketDetail.tsx
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import {
|
||||||
|
StatusBadge,
|
||||||
|
PrimaryButton,
|
||||||
|
DeleteConfirmationModal,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Database,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
storageBucketService,
|
||||||
|
type StorageBucket,
|
||||||
|
} from "@/services/storage-bucket-service";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
|
||||||
|
interface StorageBucketDetailProps {
|
||||||
|
bucketId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onRefreshList: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageBucketDetail = ({
|
||||||
|
bucketId,
|
||||||
|
onBack,
|
||||||
|
onRefreshList,
|
||||||
|
}: StorageBucketDetailProps) => {
|
||||||
|
const [bucket, setBucket] = useState<StorageBucket | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [unassignConfirmOpen, setUnassignConfirmOpen] = useState(false);
|
||||||
|
const [tenantToUnassign, setTenantToUnassign] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [isUnassigning, setIsUnassigning] = useState(false);
|
||||||
|
|
||||||
|
const fetchBucketDetail = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const full = await storageBucketService.getById(bucketId);
|
||||||
|
setBucket(full);
|
||||||
|
} catch {
|
||||||
|
showToast.error("Failed to load bucket details");
|
||||||
|
onBack();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [bucketId, onBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBucketDetail();
|
||||||
|
}, [fetchBucketDetail]);
|
||||||
|
|
||||||
|
const handleUnassignClick = (tenantId: string, tenantName: string) => {
|
||||||
|
setTenantToUnassign({ id: tenantId, name: tenantName });
|
||||||
|
setUnassignConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmUnassign = async () => {
|
||||||
|
if (!tenantToUnassign) return;
|
||||||
|
setIsUnassigning(true);
|
||||||
|
try {
|
||||||
|
await storageBucketService.unassignFromTenant(tenantToUnassign.id);
|
||||||
|
showToast.success(`Successfully unassigned from tenant ${tenantToUnassign.name}`);
|
||||||
|
setUnassignConfirmOpen(false);
|
||||||
|
setTenantToUnassign(null);
|
||||||
|
fetchBucketDetail();
|
||||||
|
onRefreshList();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err?.response?.data?.error?.message || "Failed to unassign tenant");
|
||||||
|
} finally {
|
||||||
|
setIsUnassigning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Settings" },
|
||||||
|
{ label: "Storage Buckets", path: "/settings/storage-buckets" },
|
||||||
|
{ label: "Bucket Details" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-2 bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span className="text-sm text-gray-500 font-medium">Loading bucket details...</span>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Settings" },
|
||||||
|
{ label: "Storage Buckets", path: "/settings/storage-buckets" },
|
||||||
|
{ label: "Bucket Details" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-2 bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<span className="text-sm text-red-500 font-medium">Bucket not found</span>
|
||||||
|
<PrimaryButton onClick={onBack}>Back to List</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Settings" },
|
||||||
|
{ label: "Storage Buckets", path: "/settings/storage-buckets" },
|
||||||
|
{ label: "Bucket Details" },
|
||||||
|
]}
|
||||||
|
pageHeader={{
|
||||||
|
title: bucket.name,
|
||||||
|
description: bucket.description || "Detailed configuration and tenant assignments for this storage bucket.",
|
||||||
|
action: (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#112868] transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span>Back to List</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Config Card */}
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-lg border border-gray-200 shadow-sm p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 border-b border-gray-150 pb-3 mb-4">
|
||||||
|
General Configuration
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Bucket Name</span>
|
||||||
|
<span className="font-semibold text-gray-900 text-base">{bucket.name}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Storage Type</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||||||
|
<Database className="w-3.5 h-3.5" />
|
||||||
|
{bucket.storage_type === "azure" ? "Azure Blob Storage" : "Local Filesystem"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{bucket.description && (
|
||||||
|
<div className="col-span-1 md:col-span-2">
|
||||||
|
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Description</span>
|
||||||
|
<span className="text-gray-700 block mt-1 leading-relaxed">{bucket.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Status</span>
|
||||||
|
<span className="inline-block mt-1">
|
||||||
|
<StatusBadge variant={bucket.is_active ? "success" : "failure"}>
|
||||||
|
{bucket.is_active ? "Active" : "Inactive"}
|
||||||
|
</StatusBadge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Created At</span>
|
||||||
|
<span className="text-gray-700 block mt-1 font-medium">
|
||||||
|
{new Date(bucket.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Azure Params */}
|
||||||
|
{bucket.storage_type === "azure" && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 border-b border-gray-150 pb-3 mb-4">
|
||||||
|
Azure Connection Parameters
|
||||||
|
</h3>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-5 space-y-4 font-mono text-xs text-gray-800">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block font-sans text-xs uppercase tracking-wider font-semibold mb-1">
|
||||||
|
Storage Account
|
||||||
|
</span>
|
||||||
|
<span className="bg-white border border-gray-200 px-3 py-1.5 rounded block truncate select-all font-semibold">
|
||||||
|
{bucket.azure_account}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block font-sans text-xs uppercase tracking-wider font-semibold mb-1">
|
||||||
|
Container Name
|
||||||
|
</span>
|
||||||
|
<span className="bg-white border border-gray-200 px-3 py-1.5 rounded block truncate select-all font-semibold">
|
||||||
|
{bucket.azure_container}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block font-sans text-xs uppercase tracking-wider font-semibold mb-1">
|
||||||
|
Container URL
|
||||||
|
</span>
|
||||||
|
<span className="bg-white border border-gray-200 px-3 py-1.5 rounded block truncate select-all">
|
||||||
|
{bucket.azure_url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{bucket.azure_sas_token_masked && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 block font-sans text-xs uppercase tracking-wider font-semibold mb-1">
|
||||||
|
SAS Token (Masked)
|
||||||
|
</span>
|
||||||
|
<span className="bg-white border border-gray-200 px-3 py-1.5 rounded block font-mono text-gray-400 select-none">
|
||||||
|
{bucket.azure_sas_token_masked}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignments Card */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6 flex flex-col min-h-[300px]">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 border-b border-gray-150 pb-3 mb-4 flex items-center justify-between">
|
||||||
|
<span>Assigned Tenants</span>
|
||||||
|
<span className="bg-blue-100 text-blue-800 text-xs font-semibold px-2.5 py-0.5 rounded-full">
|
||||||
|
{bucket.assigned_tenants?.length ?? 0}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!bucket.assigned_tenants || bucket.assigned_tenants.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 bg-gray-50 border border-dashed border-gray-300 rounded-lg">
|
||||||
|
<Users className="w-10 h-10 text-gray-400 mb-3" />
|
||||||
|
<p className="text-sm text-gray-500 font-medium">No tenants assigned to this bucket</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Assign this bucket to a tenant from the main list page actions.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-hidden border border-gray-200 rounded-lg">
|
||||||
|
<div className="overflow-y-auto max-h-[400px]">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm text-left">
|
||||||
|
<thead className="bg-gray-50 text-xs text-gray-500 uppercase tracking-wider font-semibold">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Tenant Name</th>
|
||||||
|
<th className="px-4 py-3">Assigned At</th>
|
||||||
|
<th className="px-4 py-3 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{bucket.assigned_tenants.map((t) => (
|
||||||
|
<tr key={t.tenant_id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-semibold text-gray-900">{t.tenant_name}</div>
|
||||||
|
{t.notes && (
|
||||||
|
<div className="text-xs text-gray-400 italic mt-0.5 max-w-[180px] truncate" title={t.notes}>
|
||||||
|
{t.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">
|
||||||
|
{new Date(t.assigned_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUnassignClick(t.tenant_id, t.tenant_name)}
|
||||||
|
className="text-xs text-red-600 hover:text-red-800 font-semibold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete/Unassign Confirmation Modal */}
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
isOpen={unassignConfirmOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setUnassignConfirmOpen(false);
|
||||||
|
setTenantToUnassign(null);
|
||||||
|
}}
|
||||||
|
onConfirm={confirmUnassign}
|
||||||
|
title="Unassign Storage Bucket"
|
||||||
|
message="Are you sure you want to unassign this bucket from tenant"
|
||||||
|
itemName={tenantToUnassign?.name || ""}
|
||||||
|
isLoading={isUnassigning}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
652
src/pages/superadmin/StorageBuckets.tsx
Normal file
652
src/pages/superadmin/StorageBuckets.tsx
Normal file
@ -0,0 +1,652 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
StatusBadge,
|
||||||
|
ActionDropdown,
|
||||||
|
PrimaryButton,
|
||||||
|
DeleteConfirmationModal,
|
||||||
|
Modal,
|
||||||
|
FormField,
|
||||||
|
FormSelect,
|
||||||
|
FormTextArea,
|
||||||
|
type Column,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Database,
|
||||||
|
Building2,
|
||||||
|
AlertCircle,
|
||||||
|
Users,
|
||||||
|
Share2,
|
||||||
|
CloudUpload,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
storageBucketService,
|
||||||
|
type StorageBucket,
|
||||||
|
} from "@/services/storage-bucket-service";
|
||||||
|
import { tenantService } from "@/services/tenant-service";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
import { StorageBucketDetail } from "./StorageBucketDetail";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Bucket Form Modal
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
interface BucketFormProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
bucket?: StorageBucket | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBucketSchema = (isEdit: boolean) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Bucket name is required")
|
||||||
|
.max(255, "Maximum 255 characters allowed"),
|
||||||
|
description: z.string().optional().or(z.literal("")),
|
||||||
|
storage_type: z.enum(["azure", "local"], {
|
||||||
|
error: "Storage type is required",
|
||||||
|
}),
|
||||||
|
azure_account: z.string().optional().or(z.literal("")),
|
||||||
|
azure_container: z.string().optional().or(z.literal("")),
|
||||||
|
azure_sas_token: z.string().optional().or(z.literal("")),
|
||||||
|
azure_url: z.string().optional().or(z.literal("")),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.storage_type === "azure") {
|
||||||
|
if (!data.azure_account || data.azure_account.trim() === "") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["azure_account"],
|
||||||
|
message: "Storage account is required for Azure",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.azure_container || data.azure_container.trim() === "") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["azure_container"],
|
||||||
|
message: "Container name is required for Azure",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.azure_url || data.azure_url.trim() === "") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["azure_url"],
|
||||||
|
message: "Container URL is required for Azure",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
new URL(data.azure_url);
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["azure_url"],
|
||||||
|
message: "Please enter a valid URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isEdit && (!data.azure_sas_token || data.azure_sas_token.trim() === "")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["azure_sas_token"],
|
||||||
|
message: "SAS token is required for Azure",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type BucketFormData = z.infer<ReturnType<typeof getBucketSchema>>;
|
||||||
|
|
||||||
|
const BucketFormModal = ({ isOpen, onClose, onSuccess, bucket }: BucketFormProps) => {
|
||||||
|
const isEdit = !!bucket;
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const bucketSchema = getBucketSchema(isEdit);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
clearErrors,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<BucketFormData>({
|
||||||
|
resolver: zodResolver(bucketSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
storage_type: "azure",
|
||||||
|
azure_account: "",
|
||||||
|
azure_container: "",
|
||||||
|
azure_sas_token: "",
|
||||||
|
azure_url: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageTypeValue = watch("storage_type");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (bucket) {
|
||||||
|
reset({
|
||||||
|
name: bucket.name,
|
||||||
|
description: bucket.description || "",
|
||||||
|
storage_type: bucket.storage_type,
|
||||||
|
azure_account: bucket.azure_account || "",
|
||||||
|
azure_container: bucket.azure_container || "",
|
||||||
|
azure_sas_token: "", // never pre-fill the masked token
|
||||||
|
azure_url: bucket.azure_url || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
storage_type: "azure",
|
||||||
|
azure_account: "",
|
||||||
|
azure_container: "",
|
||||||
|
azure_sas_token: "",
|
||||||
|
azure_url: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearErrors();
|
||||||
|
}
|
||||||
|
}, [bucket, isOpen, reset, clearErrors]);
|
||||||
|
|
||||||
|
const handleSubmitForm = async (data: BucketFormData) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isEdit && bucket) {
|
||||||
|
// Don't send empty SAS token on edit (means "don't change it")
|
||||||
|
const payload = { ...data };
|
||||||
|
if (!payload.azure_sas_token) delete payload.azure_sas_token;
|
||||||
|
await storageBucketService.update(bucket.id, payload as any);
|
||||||
|
showToast.success("Bucket updated successfully");
|
||||||
|
} else {
|
||||||
|
await storageBucketService.create(data as any);
|
||||||
|
showToast.success("Bucket created successfully");
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err?.response?.data?.error?.message || "Failed to save bucket");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEdit ? "Edit Storage Bucket" : "Create Storage Bucket"}
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(handleSubmitForm)} className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<FormField
|
||||||
|
label="Bucket Name"
|
||||||
|
required
|
||||||
|
placeholder="e.g. QAssure Primary Storage"
|
||||||
|
error={errors.name?.message}
|
||||||
|
{...register("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<FormTextArea
|
||||||
|
label="Description"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Optional description"
|
||||||
|
error={errors.description?.message}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Storage Type */}
|
||||||
|
<FormSelect
|
||||||
|
label="Storage Type"
|
||||||
|
required
|
||||||
|
placeholder="Select Storage Type"
|
||||||
|
options={[
|
||||||
|
{ value: "azure", label: "Azure Blob Storage" },
|
||||||
|
{ value: "local", label: "Local Filesystem" },
|
||||||
|
]}
|
||||||
|
value={storageTypeValue}
|
||||||
|
onValueChange={(val) => setValue("storage_type", val as "azure" | "local", { shouldValidate: true })}
|
||||||
|
error={errors.storage_type?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Azure Fields */}
|
||||||
|
{storageTypeValue === "azure" && (
|
||||||
|
<div className="space-y-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-xs font-semibold text-blue-700 uppercase tracking-wider">
|
||||||
|
Azure Blob Storage Configuration
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
label="Storage Account"
|
||||||
|
required
|
||||||
|
placeholder="e.g. mystoraccount"
|
||||||
|
error={errors.azure_account?.message}
|
||||||
|
{...register("azure_account")}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Container Name"
|
||||||
|
required
|
||||||
|
placeholder="e.g. qassure-files"
|
||||||
|
error={errors.azure_container?.message}
|
||||||
|
{...register("azure_container")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Container URL"
|
||||||
|
required
|
||||||
|
type="url"
|
||||||
|
placeholder="https://myaccount.blob.core.windows.net/mycontainer"
|
||||||
|
error={errors.azure_url?.message}
|
||||||
|
{...register("azure_url")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={`SAS Token${isEdit ? " (leave blank to keep existing)" : ""}`}
|
||||||
|
type="password"
|
||||||
|
required={!isEdit}
|
||||||
|
placeholder={isEdit ? "••••••••••••••••" : "sp=racwdli&st=..."}
|
||||||
|
error={errors.azure_sas_token?.message}
|
||||||
|
helperText="Stored encrypted (AES-256-GCM). Never shown again in plaintext."
|
||||||
|
{...register("azure_sas_token")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<PrimaryButton type="submit" disabled={saving}>
|
||||||
|
{saving ? "Saving..." : isEdit ? "Update Bucket" : "Create Bucket"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Assign Bucket Modal
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
interface AssignModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
bucket: StorageBucket | null;
|
||||||
|
mode: "assign" | "assign-self";
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssignBucketModal = ({ isOpen, onClose, onSuccess, bucket, mode }: AssignModalProps) => {
|
||||||
|
const [tenants, setTenants] = useState<any[]>([]);
|
||||||
|
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
tenantService.getAll(1, 200).then((res: any) => {
|
||||||
|
setTenants(res.tenants || res.data || []);
|
||||||
|
}).catch(() => {});
|
||||||
|
setSelectedTenantId("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleAssign = async () => {
|
||||||
|
if (!bucket || !selectedTenantId) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (mode === "assign-self") {
|
||||||
|
await storageBucketService.assignSelfBucket(bucket.id, selectedTenantId);
|
||||||
|
showToast.success("Your bucket has been shared with the selected tenant");
|
||||||
|
} else {
|
||||||
|
await storageBucketService.assignToTenant(bucket.id, selectedTenantId);
|
||||||
|
showToast.success("Bucket assigned to tenant successfully");
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err?.response?.data?.error?.message || "Failed to assign bucket");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={mode === "assign-self" ? "Share My Bucket with Tenant" : "Assign Bucket to Tenant"}
|
||||||
|
maxWidth="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mode === "assign-self" && (
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<Share2 className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
You are sharing <strong>{bucket?.name}</strong> (your QAssure bucket) with a tenant.
|
||||||
|
Tenant files will be uploaded to YOUR storage account. You can reassign anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormSelect
|
||||||
|
label="Select Tenant"
|
||||||
|
required
|
||||||
|
placeholder="Select a tenant"
|
||||||
|
options={tenants.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.name} ${t.slug ? `(${t.slug})` : ""}`,
|
||||||
|
}))}
|
||||||
|
value={selectedTenantId}
|
||||||
|
onValueChange={(val) => setSelectedTenantId(val)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<PrimaryButton onClick={handleAssign} disabled={!selectedTenantId || saving}>
|
||||||
|
{saving ? "Assigning..." : mode === "assign-self" ? "Share My Bucket" : "Assign Bucket"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Main Page
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
const StorageBucketsPage = () => {
|
||||||
|
const [buckets, setBuckets] = useState<StorageBucket[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [selectedBucket, setSelectedBucket] = useState<StorageBucket | null>(null);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
|
const [assignMode, setAssignMode] = useState<"assign" | "assign-self">("assign");
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(10);
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"list" | "detail">("list");
|
||||||
|
const [selectedBucketId, setSelectedBucketId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleViewDetails = (bucket: StorageBucket) => {
|
||||||
|
setSelectedBucketId(bucket.id);
|
||||||
|
setViewMode("detail");
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBuckets = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await storageBucketService.list();
|
||||||
|
setBuckets(data);
|
||||||
|
} catch {
|
||||||
|
showToast.error("Failed to load storage buckets");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBuckets();
|
||||||
|
}, [fetchBuckets]);
|
||||||
|
|
||||||
|
const handleEdit = (bucket: StorageBucket) => {
|
||||||
|
setSelectedBucket(bucket);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (bucket: StorageBucket) => {
|
||||||
|
setSelectedBucket(bucket);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssign = (bucket: StorageBucket, mode: "assign" | "assign-self") => {
|
||||||
|
setSelectedBucket(bucket);
|
||||||
|
setAssignMode(mode);
|
||||||
|
setAssignOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!selectedBucket) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await storageBucketService.delete(selectedBucket.id);
|
||||||
|
showToast.success("Bucket deactivated");
|
||||||
|
setDeleteOpen(false);
|
||||||
|
fetchBuckets();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(err?.response?.data?.error?.message || "Failed to delete bucket");
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const paged = buckets.slice((currentPage - 1) * limit, currentPage * limit);
|
||||||
|
|
||||||
|
const columns: Column<StorageBucket>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "Bucket Name",
|
||||||
|
render: (b) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<CloudUpload className="w-4 h-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm text-gray-900">{b.name}</p>
|
||||||
|
{b.description && (
|
||||||
|
<p className="text-xs text-gray-500 truncate max-w-[200px]">{b.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "storage_type",
|
||||||
|
label: "Type",
|
||||||
|
render: (b) => (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||||||
|
<Database className="w-3 h-3" />
|
||||||
|
{b.storage_type === "azure" ? "Azure Blob" : "Local FS"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "azure_account",
|
||||||
|
label: "Account / Container",
|
||||||
|
render: (b) =>
|
||||||
|
b.storage_type === "azure" ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-mono text-gray-800">{b.azure_account}</p>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">{b.azure_container}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm italic">Local filesystem</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "assigned_tenants_count",
|
||||||
|
label: "Assigned To",
|
||||||
|
render: (b) => (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-600">
|
||||||
|
<Users className="w-3.5 h-3.5 text-gray-400" />
|
||||||
|
<span>{b.assigned_tenants_count ?? 0} tenant{(b.assigned_tenants_count ?? 0) !== 1 ? "s" : ""}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "is_active",
|
||||||
|
label: "Status",
|
||||||
|
render: (b) => (
|
||||||
|
<StatusBadge variant={b.is_active ? "success" : "failure"}>
|
||||||
|
{b.is_active ? "Active" : "Inactive"}
|
||||||
|
</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
|
render: (b) => {
|
||||||
|
const dropdownActions = [
|
||||||
|
{
|
||||||
|
label: "View Details",
|
||||||
|
icon: <Eye className="w-3.5 h-3.5" />,
|
||||||
|
onClick: () => handleViewDetails(b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: <Edit className="w-3.5 h-3.5" />,
|
||||||
|
onClick: () => handleEdit(b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="w-3.5 h-3.5 text-red-600" />,
|
||||||
|
onClick: () => handleDelete(b),
|
||||||
|
variant: "danger" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Assign to Tenant",
|
||||||
|
icon: <Building2 className="w-3.5 h-3.5" />,
|
||||||
|
onClick: () => handleAssign(b, "assign"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return <ActionDropdown actions={dropdownActions} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (viewMode === "detail" && selectedBucketId) {
|
||||||
|
return (
|
||||||
|
<StorageBucketDetail
|
||||||
|
bucketId={selectedBucketId}
|
||||||
|
onBack={() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setSelectedBucketId(null);
|
||||||
|
}}
|
||||||
|
onRefreshList={fetchBuckets}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: "Storage Buckets",
|
||||||
|
description:
|
||||||
|
"Manage cloud storage bucket configurations for the platform. Assign buckets to tenants to enable file uploads.",
|
||||||
|
action: (
|
||||||
|
<PrimaryButton
|
||||||
|
id="create-bucket-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBucket(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Bucket
|
||||||
|
</PrimaryButton>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Info banner */}
|
||||||
|
<div className="mb-4 flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold mb-1">Tenant File Uploads</p>
|
||||||
|
<p>
|
||||||
|
Each tenant must have an active bucket assigned before they can upload files.
|
||||||
|
You can assign your own QAssure bucket to a tenant using <strong>"Share My Bucket"</strong>,
|
||||||
|
or assign any bucket via <strong>"Assign to Tenant"</strong>.
|
||||||
|
Changing a bucket does <em>not</em> affect previously uploaded files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={paged}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
emptyMessage="No storage buckets configured yet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{buckets.length > limit && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={Math.ceil(buckets.length / limit)}
|
||||||
|
totalItems={buckets.length}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bucket Create/Edit */}
|
||||||
|
<BucketFormModal
|
||||||
|
isOpen={formOpen}
|
||||||
|
onClose={() => setFormOpen(false)}
|
||||||
|
onSuccess={fetchBuckets}
|
||||||
|
bucket={selectedBucket}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Assign Modal */}
|
||||||
|
<AssignBucketModal
|
||||||
|
isOpen={assignOpen}
|
||||||
|
onClose={() => setAssignOpen(false)}
|
||||||
|
onSuccess={fetchBuckets}
|
||||||
|
bucket={selectedBucket}
|
||||||
|
mode={assignMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirm */}
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
isOpen={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title="Deactivate Storage Bucket"
|
||||||
|
message="Are you sure you want to deactivate this bucket? It cannot be deleted while assigned to active tenants."
|
||||||
|
itemName={selectedBucket?.name || ""}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StorageBucketsPage;
|
||||||
205
src/pages/tenant/StorageConfig.tsx
Normal file
205
src/pages/tenant/StorageConfig.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||||
|
import {
|
||||||
|
storageBucketService,
|
||||||
|
type StorageBucket,
|
||||||
|
} from "@/services/storage-bucket-service";
|
||||||
|
import {
|
||||||
|
CloudUpload,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Database,
|
||||||
|
Server,
|
||||||
|
Container,
|
||||||
|
Globe,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
|
||||||
|
const StorageConfigPage = () => {
|
||||||
|
const tenantId = useAppSelector((state) => state.auth.tenantId);
|
||||||
|
const [bucket, setBucket] = useState<StorageBucket | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchBucket = async () => {
|
||||||
|
if (!tenantId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await storageBucketService.getActiveForTenant(tenantId);
|
||||||
|
setBucket(data);
|
||||||
|
} catch {
|
||||||
|
showToast.error("Failed to load storage configuration");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBucket();
|
||||||
|
}, [tenantId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: "Storage Configuration",
|
||||||
|
description: "View your current file storage bucket configuration.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<RefreshCw className="w-6 h-6 text-blue-500 animate-spin mr-3" />
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Loading storage configuration...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : bucket ? (
|
||||||
|
<>
|
||||||
|
{/* Status banner */}
|
||||||
|
<div className="flex items-start gap-3 p-4 mb-6 bg-green-50 border border-green-200 rounded-xl">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-green-800">
|
||||||
|
Storage bucket is configured
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-700 mt-0.5">
|
||||||
|
Your files are being stored in the assigned bucket. You can
|
||||||
|
upload files normally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bucket details card */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||||
|
<CloudUpload className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 text-base">
|
||||||
|
{bucket.name}
|
||||||
|
</h3>
|
||||||
|
{bucket.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{bucket.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||||||
|
<Database className="w-3 h-3" />
|
||||||
|
{bucket.storage_type === "azure"
|
||||||
|
? "Azure Blob Storage"
|
||||||
|
: "Local Filesystem"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bucket.storage_type === "azure" && (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4">
|
||||||
|
<Server className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">
|
||||||
|
Storage Account
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-sm text-gray-900">
|
||||||
|
{bucket.azure_account}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4">
|
||||||
|
<Container className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Container</p>
|
||||||
|
<p className="font-mono text-sm text-gray-900">
|
||||||
|
{bucket.azure_container}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4">
|
||||||
|
<Globe className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Endpoint URL</p>
|
||||||
|
<p className="font-mono text-xs text-gray-700 break-all">
|
||||||
|
{bucket.azure_url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 bg-gray-50">
|
||||||
|
<div className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">SAS Token</p>
|
||||||
|
<p className="font-mono text-sm text-gray-400">
|
||||||
|
{bucket.azure_sas_token_masked || "••••••••••••••••"}
|
||||||
|
<span className="ml-2 text-xs text-gray-400 font-sans">
|
||||||
|
(stored securely — not visible)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bucket.storage_type === "local" && (
|
||||||
|
<div className="px-6 py-5 text-sm text-gray-600">
|
||||||
|
Files are stored on the local server filesystem. Contact your
|
||||||
|
administrator for path details.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400 mt-4">
|
||||||
|
Last updated: {new Date(bucket.updated_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* No bucket configured */
|
||||||
|
<div className="">
|
||||||
|
<div className="flex flex-col items-center text-center py-16 px-8 bg-amber-50 border border-amber-200 rounded-2xl">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-amber-100 flex items-center justify-center mb-4">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-amber-900 mb-2">
|
||||||
|
No Storage Bucket Configured
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-amber-700 mb-6 leading-relaxed">
|
||||||
|
Your account does not have a storage bucket assigned yet. You will
|
||||||
|
not be able to upload files until an administrator configures one
|
||||||
|
for your tenant.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 w-full max-w-sm">
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-white border border-amber-200 rounded-lg text-left">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-gray-700">
|
||||||
|
Contact your <strong>QAssure platform administrator</strong>{" "}
|
||||||
|
and request a storage bucket assignment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-white border border-amber-200 rounded-lg text-left">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-gray-700">
|
||||||
|
The admin can assign the{" "}
|
||||||
|
<strong>QAssure shared bucket</strong> or set up a dedicated
|
||||||
|
bucket for your organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="refresh-storage-config-btn"
|
||||||
|
onClick={fetchBucket}
|
||||||
|
className="mt-6 flex items-center gap-2 px-4 py-2 text-sm text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-100 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StorageConfigPage;
|
||||||
@ -22,6 +22,7 @@ const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
|
|||||||
const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory"));
|
const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory"));
|
||||||
const PlatformUsers = lazy(() => import("@/pages/superadmin/Users"));
|
const PlatformUsers = lazy(() => import("@/pages/superadmin/Users"));
|
||||||
const PlatformRoles = lazy(() => import("@/pages/superadmin/Roles"));
|
const PlatformRoles = lazy(() => import("@/pages/superadmin/Roles"));
|
||||||
|
const StorageBuckets = lazy(() => import("@/pages/superadmin/StorageBuckets"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -120,4 +121,8 @@ export const superAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/settings/ai-fallbacks",
|
path: "/settings/ai-fallbacks",
|
||||||
element: <LazyRoute component={AIFallbackHistory} />,
|
element: <LazyRoute component={AIFallbackHistory} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/storage-buckets",
|
||||||
|
element: <LazyRoute component={StorageBuckets} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -62,6 +62,7 @@ const TenantAIProviderCreate = lazy(
|
|||||||
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
||||||
const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy"));
|
const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy"));
|
||||||
const ElectronicSignatures = lazy(() => import("@/pages/tenant/ElectronicSignatures"));
|
const ElectronicSignatures = lazy(() => import("@/pages/tenant/ElectronicSignatures"));
|
||||||
|
const StorageConfig = lazy(() => import("@/pages/tenant/StorageConfig"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -264,6 +265,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/settings/security-policy",
|
path: "/tenant/settings/security-policy",
|
||||||
element: <LazyRoute component={SecurityPolicy} />,
|
element: <LazyRoute component={SecurityPolicy} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/settings/storage",
|
||||||
|
element: <LazyRoute component={StorageConfig} />,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// path: "/tenant/ai/knowledge",
|
// path: "/tenant/ai/knowledge",
|
||||||
// element: <LazyRoute component={AIGateway} />,
|
// element: <LazyRoute component={AIGateway} />,
|
||||||
|
|||||||
116
src/services/storage-bucket-service.ts
Normal file
116
src/services/storage-bucket-service.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Storage Bucket Service — QAssure Frontend
|
||||||
|
* API client for tenant-wise storage bucket management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './api-client';
|
||||||
|
|
||||||
|
export interface StorageBucket {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
owner_tenant_id: string;
|
||||||
|
storage_type: 'azure' | 'local';
|
||||||
|
azure_account?: string;
|
||||||
|
azure_container?: string;
|
||||||
|
azure_sas_token_masked?: string;
|
||||||
|
azure_url?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
assigned_tenants_count?: number;
|
||||||
|
assigned_tenants?: Array<{
|
||||||
|
tenant_id: string;
|
||||||
|
tenant_name: string;
|
||||||
|
assigned_at: string;
|
||||||
|
notes?: string;
|
||||||
|
}>;
|
||||||
|
created_by?: string;
|
||||||
|
updated_by?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BucketAssignment {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
bucket_id: string;
|
||||||
|
bucket_name?: string;
|
||||||
|
storage_type?: string;
|
||||||
|
azure_account?: string;
|
||||||
|
azure_container?: string;
|
||||||
|
assigned_by?: string;
|
||||||
|
assigned_at: string;
|
||||||
|
is_active: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBucketPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
storage_type: 'azure' | 'local';
|
||||||
|
azure_account?: string;
|
||||||
|
azure_container?: string;
|
||||||
|
azure_sas_token?: string;
|
||||||
|
azure_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBucketPayload extends Partial<CreateBucketPayload> {
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storageBucketService = {
|
||||||
|
/** List all buckets (superadmin: all; tenant: own / assigned) */
|
||||||
|
list: async (): Promise<StorageBucket[]> => {
|
||||||
|
const res = await apiClient.get('/storage-buckets');
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get a single bucket by ID */
|
||||||
|
getById: async (id: string): Promise<StorageBucket> => {
|
||||||
|
const res = await apiClient.get(`/storage-buckets/${id}`);
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new bucket (owner_tenant_id auto-set by backend) */
|
||||||
|
create: async (payload: CreateBucketPayload): Promise<StorageBucket> => {
|
||||||
|
const res = await apiClient.post('/storage-buckets', payload);
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update an existing bucket */
|
||||||
|
update: async (id: string, payload: UpdateBucketPayload): Promise<StorageBucket> => {
|
||||||
|
const res = await apiClient.put(`/storage-buckets/${id}`, payload);
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Soft-delete a bucket */
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/storage-buckets/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Assign a bucket to a specific tenant (superadmin action) */
|
||||||
|
assignToTenant: async (bucketId: string, tenantId: string, notes?: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/storage-buckets/${bucketId}/assign`, { tenant_id: tenantId, notes });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** SuperAdmin: share own bucket with another tenant */
|
||||||
|
assignSelfBucket: async (bucketId: string, tenantId: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/storage-buckets/${bucketId}/assign-self`, { tenant_id: tenantId });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Unassign bucket from a tenant */
|
||||||
|
unassignFromTenant: async (tenantId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/storage-buckets/tenant/${tenantId}/unassign`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get the currently active bucket for a tenant */
|
||||||
|
getActiveForTenant: async (tenantId: string): Promise<StorageBucket | null> => {
|
||||||
|
const res = await apiClient.get(`/storage-buckets/tenant/${tenantId}/active`);
|
||||||
|
return res.data.data ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get assignment history for a tenant (superadmin only) */
|
||||||
|
getAssignmentHistory: async (tenantId: string): Promise<BucketAssignment[]> => {
|
||||||
|
const res = await apiClient.get(`/storage-buckets/tenant/${tenantId}/history`);
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user