feat: implement storage bucket management with configuration pages and API integration

This commit is contained in:
Yashwin 2026-06-23 13:15:51 +05:30
parent b0fe466a3d
commit 69a85a1c9b
7 changed files with 1287 additions and 0 deletions

View File

@ -109,6 +109,7 @@ const superAdminSystemMenu: MenuItem[] = [
{ label: "SMTP Config", path: "/settings/smtp" },
{ label: "Failed Emails", path: "/settings/failed-emails" },
{ label: "AI Fallback Monitoring",path: "/settings/ai-fallbacks" },
{ label: "Storage Buckets", path: "/settings/storage-buckets" },
],
requiredPermission: { resource: "settings", action: "read" },
},
@ -301,6 +302,10 @@ const tenantAdminSystemMenu: MenuItem[] = [
label: "Failed Emails",
path: "/tenant/settings/failed-emails",
},
{
label: "Storage Config",
path: "/tenant/settings/storage",
},
],
requiredPermission: { resource: "tenants" },
},

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

View 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;

View 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;

View File

@ -22,6 +22,7 @@ const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory"));
const PlatformUsers = lazy(() => import("@/pages/superadmin/Users"));
const PlatformRoles = lazy(() => import("@/pages/superadmin/Roles"));
const StorageBuckets = lazy(() => import("@/pages/superadmin/StorageBuckets"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@ -120,4 +121,8 @@ export const superAdminRoutes: RouteConfig[] = [
path: "/settings/ai-fallbacks",
element: <LazyRoute component={AIFallbackHistory} />,
},
{
path: "/settings/storage-buckets",
element: <LazyRoute component={StorageBuckets} />,
},
];

View File

@ -62,6 +62,7 @@ const TenantAIProviderCreate = lazy(
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy"));
const ElectronicSignatures = lazy(() => import("@/pages/tenant/ElectronicSignatures"));
const StorageConfig = lazy(() => import("@/pages/tenant/StorageConfig"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@ -264,6 +265,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/settings/security-policy",
element: <LazyRoute component={SecurityPolicy} />,
},
{
path: "/tenant/settings/storage",
element: <LazyRoute component={StorageConfig} />,
},
// {
// path: "/tenant/ai/knowledge",
// element: <LazyRoute component={AIGateway} />,

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