diff --git a/frontend/src/components/UserPermissionsManager.tsx b/frontend/src/components/UserPermissionsManager.tsx index 63efb4cb..d6aed38c 100644 --- a/frontend/src/components/UserPermissionsManager.tsx +++ b/frontend/src/components/UserPermissionsManager.tsx @@ -1,401 +1,268 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useCallback } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { toast } from "sonner"; - -export type TeamRole = "Owner" | "Administrator" | "Developer" | "Support" | "Viewer"; - -export interface TeamMember { - id: string; - email: string; - role: TeamRole; - status: "Active" | "Invited"; - joinedAt: string; +import { useTranslations } from "next-intl"; +import { usePermissionsStore, type Permission } from "@/hooks/usePermissionsStore"; + +export interface UserPermissionsManagerProps { + userId: string; + showCategories?: boolean; + isReadOnly?: boolean; + onPermissionsChange?: (permissions: Permission[]) => Promise | void; } -const DEFAULT_MEMBERS: TeamMember[] = [ - { - id: "mem_1", - email: "owner@pluto.storage", - role: "Owner", - status: "Active", - joinedAt: "2026-01-10", - }, - { - id: "mem_2", - email: "lead-dev@pluto.storage", - role: "Developer", - status: "Active", - joinedAt: "2026-03-15", - }, - { - id: "mem_3", - email: "support-agent@pluto.storage", - role: "Support", - status: "Invited", - joinedAt: "2026-05-27", - }, +const CATEGORY_ORDER: Permission["category"][] = [ + "payment", + "webhook", + "analytics", + "admin", ]; -const ROLE_DESCRIPTIONS: Record = { - Owner: "Full access to billing, key rotation, database, and all settings.", - Administrator: "Can manage all settings, webhooks, and team members except key rotation.", - Developer: "Can read/write API keys, view payment logs, and test in sandbox.", - Support: "Can view payments, access dashboard charts, and process refunds.", - Viewer: "Read-only access to dashboard statistics and payment logs.", +const rowVariants = { + hidden: { opacity: 0, y: -6 }, + visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 380, damping: 28 } }, + exit: { opacity: 0, y: 6, transition: { duration: 0.15 } }, }; -export default function UserPermissionsManager() { - const [members, setMembers] = useState([]); - const [inviteEmail, setInviteEmail] = useState(""); - const [inviteRole, setInviteRole] = useState("Developer"); - const [isInviting, setIsInviting] = useState(false); - const [filterRole, setFilterRole] = useState("All"); - - // Load from localStorage or default on mount - useEffect(() => { - const saved = localStorage.getItem("pluto_team_members"); - if (saved) { - try { - setMembers(JSON.parse(saved)); - } catch { - setMembers(DEFAULT_MEMBERS); - } - } else { - setMembers(DEFAULT_MEMBERS); - localStorage.setItem("pluto_team_members", JSON.stringify(DEFAULT_MEMBERS)); - } - }, []); - - const saveToStorage = (updatedList: TeamMember[]) => { - localStorage.setItem("pluto_team_members", JSON.stringify(updatedList)); - }; - - const handleInvite = async (e: React.FormEvent) => { - e.preventDefault(); - if (!inviteEmail.trim()) { - toast.error("Please enter a valid email address."); - return; - } - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(inviteEmail.trim())) { - toast.error("Invalid email address format."); - return; - } - - if (members.some((m) => m.email.toLowerCase() === inviteEmail.trim().toLowerCase())) { - toast.error("A team member with this email already exists."); - return; - } - - setIsInviting(true); - - const newMember: TeamMember = { - id: `mem_${Date.now()}`, - email: inviteEmail.trim().toLowerCase(), - role: inviteRole, - status: "Invited", - joinedAt: new Date().toISOString().split("T")[0], - }; - - // Keep reference of previous list for rollback if API fails - const previousMembers = [...members]; - - // Optimistic Update: Immediately add the new member to the list - const optimisticallyUpdatedMembers = [...members, newMember]; - setMembers(optimisticallyUpdatedMembers); - setInviteEmail(""); - - try { - // Simulate API network request latency - await new Promise((resolve, reject) => { - setTimeout(() => { - // 95% success rate for simulation - if (Math.random() > 0.05) { - resolve(true); - } else { - reject(new Error("API server timed out")); - } - }, 800); - }); - - saveToStorage(optimisticallyUpdatedMembers); - toast.success(`Successfully invited ${newMember.email} as ${newMember.role}`); - } catch (err: unknown) { - // Revert/Rollback on failure - setMembers(previousMembers); - const msg = err instanceof Error ? err.message : "Failed to invite user"; - toast.error(`Error: ${msg}. Reverted state.`); - } finally { - setIsInviting(false); - } - }; - - const handleRoleChange = async (memberId: string, newRole: TeamRole) => { - const previousMembers = [...members]; - - // Optimistic Update: Immediately update role in list state - const optimisticallyUpdatedMembers = members.map((m) => - m.id === memberId ? { ...m, role: newRole } : m - ); - setMembers(optimisticallyUpdatedMembers); - - try { - // Simulate API latency - await new Promise((resolve) => setTimeout(resolve, 500)); - saveToStorage(optimisticallyUpdatedMembers); - toast.success("Team member role successfully updated."); - } catch { - // Revert/Rollback on failure - setMembers(previousMembers); - toast.error("Failed to update role. Reverted state."); - } - }; - - const handleRevoke = async (memberId: string) => { - const targetMember = members.find((m) => m.id === memberId); - if (!targetMember) return; - - if (targetMember.role === "Owner") { - toast.error("The account Owner's permissions cannot be revoked."); - return; - } - - if (!confirm(`Are you sure you want to revoke access for ${targetMember.email}?`)) { - return; - } - - const previousMembers = [...members]; - - // Optimistic Update: Immediately remove member from list state - const optimisticallyUpdatedMembers = members.filter((m) => m.id !== memberId); - setMembers(optimisticallyUpdatedMembers); +const categoryVariants = { + hidden: { opacity: 0, height: 0, overflow: "hidden" }, + visible: { opacity: 1, height: "auto", overflow: "visible", transition: { type: "spring", stiffness: 300, damping: 30 } }, + exit: { opacity: 0, height: 0, overflow: "hidden", transition: { duration: 0.2 } }, +}; - try { - // Simulate API latency - await new Promise((resolve) => setTimeout(resolve, 600)); - saveToStorage(optimisticallyUpdatedMembers); - toast.success(`Revoked access for ${targetMember.email}`); - } catch { - // Revert/Rollback on failure - setMembers(previousMembers); - toast.error("Failed to revoke access. Reverted state."); - } - }; +// ---------- sub-components ---------- - const filteredMembers = filterRole === "All" - ? members - : members.filter((m) => m.role === filterRole); +interface PermissionRowProps { + permission: Permission; + isPending: boolean; + isReadOnly: boolean; + onToggle: (id: string) => void; +} +function PermissionRow({ permission, isPending, isReadOnly, onToggle }: PermissionRowProps) { + const disabled = isReadOnly || isPending; return ( -
- {/* Invite Member form */} -
-
-

- Invite Team Member -

-

- Add team members and define their exact workspace accessibility level. -

+ +
+ {permission.name} + {permission.description} +
+ + +
+ ); +} -
-
- - setInviteEmail(e.target.value)} - disabled={isInviting} - className="h-11 rounded-xl border border-[#E8E8E8] bg-[#F9F9F9] px-4 text-sm text-[#0A0A0A] placeholder-slate-400 focus:border-[#4a6fa5] focus:bg-white outline-none transition-all disabled:opacity-50" - /> -
- -
- - -
+interface CategorySectionProps { + category: Permission["category"]; + items: Permission[]; + isExpanded: boolean; + pendingIds: Set; + isReadOnly: boolean; + label: string; + onToggleCategory: (category: string) => void; + onTogglePermission: (id: string) => void; +} - + + + {isExpanded && ( + - {isInviting ? ( - <> - - - - - Sending... - - ) : ( - "Send Invite" - )} - - - - {/* Role description box */} -
-

- Role Access Level: {inviteRole} -

-

- {ROLE_DESCRIPTIONS[inviteRole]} -

-
-
+ + {items.map((p) => ( + + ))} + + + )} + +
+ ); +} - {/* Member List section */} -
-
-
-

- Active Workspace Team -

-

- {filteredMembers.length} active or pending members on your merchant account. -

-
+// ---------- main component ---------- + +export function UserPermissionsManager({ + userId: _userId, + showCategories = false, + isReadOnly = false, + onPermissionsChange, +}: UserPermissionsManagerProps) { + const t = useTranslations("permissions"); + const { permissions, setPermissions } = usePermissionsStore(); + const [pendingIds, setPendingIds] = useState>(new Set()); + const [expandedCategories, setExpandedCategories] = useState>( + new Set(CATEGORY_ORDER) + ); - {/* Role Filter */} -
- - -
-
+ const handleToggle = useCallback( + async (permissionId: string) => { + if (isReadOnly || pendingIds.has(permissionId)) return; - {/* Table of Members */} -
- - - - - - - - - - - - {filteredMembers.map((member) => ( - - + const previous = permissions.map((p: Permission) => ({ ...p })); + const updated = permissions.map((p: Permission) => + p.id === permissionId + ? { ...p, granted: !p.granted, lastModified: new Date().toISOString() } + : p + ); - + setPermissions(updated); + setPendingIds((ids: Set) => new Set(ids).add(permissionId)); - + try { + await onPermissionsChange?.(updated); + toast.success(t("updateSuccess")); + } catch { + setPermissions(previous); + toast.error(t("updateError")); + } finally { + setPendingIds((ids: Set) => { + const next = new Set(ids); + next.delete(permissionId); + return next; + }); + } + }, + [isReadOnly, pendingIds, permissions, setPermissions, onPermissionsChange, t] + ); - - - ))} - + const handleToggleCategory = useCallback((category: string) => { + setExpandedCategories((prev: Set) => { + const next = new Set(prev); + if (next.has(category)) next.delete(category); + else next.add(category); + return next; + }); + }, []); - {filteredMembers.length === 0 && ( - - - - )} - -
Member / EmailWorkspace RoleConnectionAction
-
- {member.email} - - Joined on {member.joinedAt} - -
-
- {member.role === "Owner" ? ( - - Owner - - ) : ( - - )} - -
- - - {member.status} - -
-
- {member.role !== "Owner" && ( - - )} -
- No team members match this filter. -
-
-
- + return ( +
0} + className="flex flex-col gap-4" + > + {isReadOnly && ( +

+ {t("readOnlyNotice")} +

+ )} + +
+ {showCategories ? ( + CATEGORY_ORDER.map((category) => { + const items = permissions.filter((p: Permission) => p.category === category); + if (items.length === 0) return null; + return ( + + ); + }) + ) : ( + + {permissions.map((p: Permission) => ( + + ))} + + )} +
+
); } + +export default UserPermissionsManager;