From 69a85a1c9b00b17f7cb99244ff4310f0f77afab9 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Tue, 23 Jun 2026 13:15:51 +0530 Subject: [PATCH] feat: implement storage bucket management with configuration pages and API integration --- src/components/layout/Sidebar.tsx | 5 + src/pages/superadmin/StorageBucketDetail.tsx | 299 +++++++++ src/pages/superadmin/StorageBuckets.tsx | 652 +++++++++++++++++++ src/pages/tenant/StorageConfig.tsx | 205 ++++++ src/routes/super-admin-routes.tsx | 5 + src/routes/tenant-admin-routes.tsx | 5 + src/services/storage-bucket-service.ts | 116 ++++ 7 files changed, 1287 insertions(+) create mode 100644 src/pages/superadmin/StorageBucketDetail.tsx create mode 100644 src/pages/superadmin/StorageBuckets.tsx create mode 100644 src/pages/tenant/StorageConfig.tsx create mode 100644 src/services/storage-bucket-service.ts diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 14c1cd9..60c6dea 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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" }, }, diff --git a/src/pages/superadmin/StorageBucketDetail.tsx b/src/pages/superadmin/StorageBucketDetail.tsx new file mode 100644 index 0000000..293e6ab --- /dev/null +++ b/src/pages/superadmin/StorageBucketDetail.tsx @@ -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(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 ( + +
+
+ Loading bucket details... +
+
+ ); + } + + if (!bucket) { + return ( + +
+ Bucket not found + Back to List +
+
+ ); + } + + return ( + + + Back to List + + ), + }} + > +
+
+ {/* Main Config Card */} +
+
+

+ General Configuration +

+
+
+ Bucket Name + {bucket.name} +
+
+ Storage Type + + + {bucket.storage_type === "azure" ? "Azure Blob Storage" : "Local Filesystem"} + +
+ {bucket.description && ( +
+ Description + {bucket.description} +
+ )} +
+ Status + + + {bucket.is_active ? "Active" : "Inactive"} + + +
+
+ Created At + + {new Date(bucket.created_at).toLocaleString()} + +
+
+
+ + {/* Azure Params */} + {bucket.storage_type === "azure" && ( +
+

+ Azure Connection Parameters +

+
+
+
+ + Storage Account + + + {bucket.azure_account} + +
+
+ + Container Name + + + {bucket.azure_container} + +
+
+
+ + Container URL + + + {bucket.azure_url} + +
+ {bucket.azure_sas_token_masked && ( +
+ + SAS Token (Masked) + + + {bucket.azure_sas_token_masked} + +
+ )} +
+
+ )} +
+ + {/* Assignments Card */} +
+

+ Assigned Tenants + + {bucket.assigned_tenants?.length ?? 0} + +

+ + {!bucket.assigned_tenants || bucket.assigned_tenants.length === 0 ? ( +
+ +

No tenants assigned to this bucket

+

Assign this bucket to a tenant from the main list page actions.

+
+ ) : ( +
+
+ + + + + + + + + + {bucket.assigned_tenants.map((t) => ( + + + + + + ))} + +
Tenant NameAssigned AtAction
+
{t.tenant_name}
+ {t.notes && ( +
+ {t.notes} +
+ )} +
+ {new Date(t.assigned_at).toLocaleDateString()} + + +
+
+
+ )} +
+
+
+ + {/* Delete/Unassign Confirmation Modal */} + { + 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} + /> +
+ ); +}; diff --git a/src/pages/superadmin/StorageBuckets.tsx b/src/pages/superadmin/StorageBuckets.tsx new file mode 100644 index 0000000..93657d7 --- /dev/null +++ b/src/pages/superadmin/StorageBuckets.tsx @@ -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>; + +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({ + 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 ( + +
+ {/* Name */} + + + {/* Description */} + + + {/* Storage Type */} + setValue("storage_type", val as "azure" | "local", { shouldValidate: true })} + error={errors.storage_type?.message} + /> + + {/* Azure Fields */} + {storageTypeValue === "azure" && ( +
+

+ Azure Blob Storage Configuration +

+ +
+ + +
+ + + + +
+ )} + + {/* Actions */} +
+ + + {saving ? "Saving..." : isEdit ? "Update Bucket" : "Create Bucket"} + +
+ +
+ ); +}; + +// ───────────────────────────────────────────── +// 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([]); + 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 ( + +
+ {mode === "assign-self" && ( +
+ +

+ You are sharing {bucket?.name} (your QAssure bucket) with a tenant. + Tenant files will be uploaded to YOUR storage account. You can reassign anytime. +

+
+ )} + + ({ + value: t.id, + label: `${t.name} ${t.slug ? `(${t.slug})` : ""}`, + }))} + value={selectedTenantId} + onValueChange={(val) => setSelectedTenantId(val)} + /> + +
+ + + {saving ? "Assigning..." : mode === "assign-self" ? "Share My Bucket" : "Assign Bucket"} + +
+
+
+ ); +}; + + + +// ───────────────────────────────────────────── +// Main Page +// ───────────────────────────────────────────── +const StorageBucketsPage = () => { + const [buckets, setBuckets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [formOpen, setFormOpen] = useState(false); + const [selectedBucket, setSelectedBucket] = useState(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(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[] = [ + { + key: "name", + label: "Bucket Name", + render: (b) => ( +
+
+ +
+
+

{b.name}

+ {b.description && ( +

{b.description}

+ )} +
+
+ ), + }, + { + key: "storage_type", + label: "Type", + render: (b) => ( + + + {b.storage_type === "azure" ? "Azure Blob" : "Local FS"} + + ), + }, + { + key: "azure_account", + label: "Account / Container", + render: (b) => + b.storage_type === "azure" ? ( +
+

{b.azure_account}

+

{b.azure_container}

+
+ ) : ( + Local filesystem + ), + }, + { + key: "assigned_tenants_count", + label: "Assigned To", + render: (b) => ( +
+ + {b.assigned_tenants_count ?? 0} tenant{(b.assigned_tenants_count ?? 0) !== 1 ? "s" : ""} +
+ ), + }, + { + key: "is_active", + label: "Status", + render: (b) => ( + + {b.is_active ? "Active" : "Inactive"} + + ), + }, + { + key: "actions", + label: "Actions", + align: "right", + render: (b) => { + const dropdownActions = [ + { + label: "View Details", + icon: , + onClick: () => handleViewDetails(b), + }, + { + label: "Edit", + icon: , + onClick: () => handleEdit(b), + }, + { + label: "Delete", + icon: , + onClick: () => handleDelete(b), + variant: "danger" as const, + }, + { + label: "Assign to Tenant", + icon: , + onClick: () => handleAssign(b, "assign"), + }, + ]; + return ; + }, + }, + ]; + + if (viewMode === "detail" && selectedBucketId) { + return ( + { + setViewMode("list"); + setSelectedBucketId(null); + }} + onRefreshList={fetchBuckets} + /> + ); + } + + return ( + { + setSelectedBucket(null); + setFormOpen(true); + }} + > + + New Bucket + + ), + }} + > + {/* Info banner */} +
+ +
+

Tenant File Uploads

+

+ Each tenant must have an active bucket assigned before they can upload files. + You can assign your own QAssure bucket to a tenant using "Share My Bucket", + or assign any bucket via "Assign to Tenant". + Changing a bucket does not affect previously uploaded files. +

+
+
+ +
+ item.id} + emptyMessage="No storage buckets configured yet" + /> + + {buckets.length > limit && ( + + )} +
+ + {/* Bucket Create/Edit */} + setFormOpen(false)} + onSuccess={fetchBuckets} + bucket={selectedBucket} + /> + + {/* Assign Modal */} + setAssignOpen(false)} + onSuccess={fetchBuckets} + bucket={selectedBucket} + mode={assignMode} + /> + + {/* Delete Confirm */} + 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} + /> + +
+ ); +}; + +export default StorageBucketsPage; diff --git a/src/pages/tenant/StorageConfig.tsx b/src/pages/tenant/StorageConfig.tsx new file mode 100644 index 0000000..519a1fb --- /dev/null +++ b/src/pages/tenant/StorageConfig.tsx @@ -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(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 ( + + {isLoading ? ( +
+ + + Loading storage configuration... + +
+ ) : bucket ? ( + <> + {/* Status banner */} +
+ +
+

+ Storage bucket is configured +

+

+ Your files are being stored in the assigned bucket. You can + upload files normally. +

+
+
+ + {/* Bucket details card */} +
+
+
+ +
+
+

+ {bucket.name} +

+ {bucket.description && ( +

+ {bucket.description} +

+ )} +
+ + + {bucket.storage_type === "azure" + ? "Azure Blob Storage" + : "Local Filesystem"} + +
+ + {bucket.storage_type === "azure" && ( +
+
+ +
+

+ Storage Account +

+

+ {bucket.azure_account} +

+
+
+ +
+ +
+

Container

+

+ {bucket.azure_container} +

+
+
+ +
+ +
+

Endpoint URL

+

+ {bucket.azure_url} +

+
+
+ +
+
+
+

SAS Token

+

+ {bucket.azure_sas_token_masked || "••••••••••••••••"} + + (stored securely — not visible) + +

+
+
+
+ )} + + {bucket.storage_type === "local" && ( +
+ Files are stored on the local server filesystem. Contact your + administrator for path details. +
+ )} +
+ +

+ Last updated: {new Date(bucket.updated_at).toLocaleString()} +

+ + ) : ( + /* No bucket configured */ +
+
+
+ +
+

+ No Storage Bucket Configured +

+

+ 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. +

+
+
+ +

+ Contact your QAssure platform administrator{" "} + and request a storage bucket assignment. +

+
+
+ +

+ The admin can assign the{" "} + QAssure shared bucket or set up a dedicated + bucket for your organization. +

+
+
+ +
+
+ )} + + ); +}; + +export default StorageConfigPage; diff --git a/src/routes/super-admin-routes.tsx b/src/routes/super-admin-routes.tsx index 37495b6..b312132 100644 --- a/src/routes/super-admin-routes.tsx +++ b/src/routes/super-admin-routes.tsx @@ -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: , }, + { + path: "/settings/storage-buckets", + element: , + }, ]; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index b92a4c4..e87eaf4 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -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: , }, + { + path: "/tenant/settings/storage", + element: , + }, // { // path: "/tenant/ai/knowledge", // element: , diff --git a/src/services/storage-bucket-service.ts b/src/services/storage-bucket-service.ts new file mode 100644 index 0000000..5763987 --- /dev/null +++ b/src/services/storage-bucket-service.ts @@ -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 { + is_active?: boolean; +} + +export const storageBucketService = { + /** List all buckets (superadmin: all; tenant: own / assigned) */ + list: async (): Promise => { + const res = await apiClient.get('/storage-buckets'); + return res.data.data; + }, + + /** Get a single bucket by ID */ + getById: async (id: string): Promise => { + 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 => { + const res = await apiClient.post('/storage-buckets', payload); + return res.data.data; + }, + + /** Update an existing bucket */ + update: async (id: string, payload: UpdateBucketPayload): Promise => { + const res = await apiClient.put(`/storage-buckets/${id}`, payload); + return res.data.data; + }, + + /** Soft-delete a bucket */ + delete: async (id: string): Promise => { + await apiClient.delete(`/storage-buckets/${id}`); + }, + + /** Assign a bucket to a specific tenant (superadmin action) */ + assignToTenant: async (bucketId: string, tenantId: string, notes?: string): Promise => { + 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 => { + await apiClient.post(`/storage-buckets/${bucketId}/assign-self`, { tenant_id: tenantId }); + }, + + /** Unassign bucket from a tenant */ + unassignFromTenant: async (tenantId: string): Promise => { + await apiClient.delete(`/storage-buckets/tenant/${tenantId}/unassign`); + }, + + /** Get the currently active bucket for a tenant */ + getActiveForTenant: async (tenantId: string): Promise => { + 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 => { + const res = await apiClient.get(`/storage-buckets/tenant/${tenantId}/history`); + return res.data.data; + }, +};