import React, { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Users, Shield, ShieldCheck, KeyRound, History, Mail, UserPlus, Trash2, LogOut, Loader2, ExternalLink, Crown, CheckCircle2, XCircle, Copy, Check, AlertTriangle, Settings, Lock, Unlock, } from "lucide-react"; import { __, sprintf, brandName } from "../lib/i18n"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../components/ui/card"; import { Alert } from "../components/ui/alert"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Select } from "../components/ui/select"; import { Badge } from "../components/ui/badge"; import { Modal } from "../components/ui/modal"; import { Switch } from "../components/ui/switch"; import { Tooltip } from "../components/ui/tooltip"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { Pagination } from "../components/shared/Pagination"; import { Table as SharedTable } from "../components/shared/Table"; import { Skeleton } from "../components/ui/skeleton"; import { useToast } from "../components/ui/toast"; import { setUserCaps } from "../hooks/useCapabilities"; import { teamApi, type CapabilityDef, type TeamInvitation, type TeamMeta, type TeamRole, type TeamUser, type TeamUserWritePayload, } from "../api/team-api"; /* -------------------------------------------------------------------------- */ /* Yatra → Team & Access — Agency-tier admin surface */ /* */ /* Mirrors the Webhooks page architecture: same upgrade-card / module- */ /* disabled-card gates, same tab strip pattern, same SharedTable primitive */ /* for every list view. Strictly composed from existing Yatra UI components */ /* — no bespoke styling, no new design tokens. */ /* -------------------------------------------------------------------------- */ type TeamTab = "members" | "roles" | "invitations" | "audit" | "settings"; const extractError = (e: any): string => { return ( e?.response?.data?.message || e?.message || __("Something went wrong.", "yatra") ); }; const getInitialTab = (): TeamTab => { if (typeof window === "undefined") return "members"; const tab = new URLSearchParams(window.location.search).get("tab"); if ( tab === "roles" || tab === "invitations" || tab === "audit" || tab === "settings" ) return tab; return "members"; }; const Team: React.FC = () => { const [activeTab, setActiveTab] = useState(() => getInitialTab()); const switchTab = (next: TeamTab) => { setActiveTab(next); if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState({}, "", url.toString()); } }; const { data: meta, isLoading: metaLoading } = useQuery({ queryKey: ["team-meta"], queryFn: () => teamApi.getMeta(), }); // Forward-looking "keep access on module disable" setting. We fetch // it via the same React Query key the Settings tab uses — sharing // the cache so flipping the toggle re-renders this banner live // (no page reload) without firing a second request. Initial paint // uses the server-injected snapshot to avoid a flicker. // // CRITICAL: this useQuery MUST stay above any early-return branches // below (meta loading / no-meta / not-agency / module-disabled). // React enforces a stable hook order across renders; if it lived // below an early return, switching from the loading state to the // ready state would introduce a "new" hook and trigger React error // #310. const { data: settingsData } = useQuery({ queryKey: ["team-settings"], queryFn: () => teamApi.getSettings(), initialData: () => { const seed = window.yatraAdmin?.teamKeepAccessOnModuleDisable; // The option defaults to FALSE server-side (security-conservative: // disabling the module strips Yatra roles unless the operator // explicitly opts in). When the localized snapshot is undefined // (very first paint on a fresh install before the toggle has // ever been touched) mirror that default so the banner + tab // contents agree from the first render. return { data: { keep_access_on_module_disable: seed === true }, } as { data: { keep_access_on_module_disable: boolean } }; }, // Don't fire the network request until the module is actually // enabled — the /team/settings endpoint requires Team & Access // to be active. Initial data still serves the banner during // loading + non-agency states. enabled: meta?.is_module_enabled === true, }); // Strict equality on TRUE keeps this consistent with the SettingsTab // toggle below (also `=== true`) so the banner and the toggle never // disagree about which side of the default we're on. const keepAccessOnDisable = settingsData?.data?.keep_access_on_module_disable === true; if (metaLoading) { return (
{/* Skeleton mirroring the loaded page: header copy + tab strip + */} {/* table card. Keeps the layout stable during /team/meta load. */}
{[0, 1, 2, 3].map((i) => ( ))}
{[0, 1, 2, 3, 4].map((i) => ( ))}
); } if (!meta) return null; if (!meta.is_agency_active) { return (
); } if (!meta.is_module_enabled) { return (
); } const tabs: Array<{ key: TeamTab; label: string; icon: any }> = [ { key: "members", label: __("Members", "yatra"), icon: Users }, { key: "roles", label: __("Roles", "yatra"), icon: Shield }, { key: "invitations", label: __("Invitations", "yatra"), icon: Mail }, { key: "audit", label: __("Audit log", "yatra"), icon: History }, { key: "settings", label: __("Settings", "yatra"), icon: Settings }, ]; return (
{/* Persistent banner. Visible on EVERY Team tab so operators */} {/* understand the disable-behavior trade-off no matter which */} {/* surface they're working on. Two variants: */} {/* - Toggle ON (permissive, opt-in): info "team keeps access" */} {/* - Toggle OFF (default, destructive on disable): warning */} {/* The "Go to Settings" button is hidden when the active tab IS */} {/* Settings (operator is already there). */} {keepAccessOnDisable ? (

{__( "Your team will keep access if you ever turn off this module", "yatra", )}

{__( "You've opted in to preserve team access when the module is disabled. Turning off Team & Access in Yatra → Modules will pause its advanced features (expiry, scopes, audit log) but your team members keep their roles and current access. You can change this in Settings.", "yatra", )}

{activeTab !== "settings" && ( )}
) : (

{__( "Heads up — turning off this module will revoke all team access", "yatra", )}

{__( "This is the default. If you ever disable Team & Access in Yatra → Modules, every Yatra role on your site (Owner, Manager, Sales Agent, plus any custom roles you built) will be removed and your team members will lose all Yatra access. Re-enabling brings back the 8 built-in roles only — custom roles and member assignments don't come back. Switch the setting ON in Settings if you'd rather keep your team's access.", "yatra", )}

{activeTab !== "settings" && ( )}
)}
{activeTab === "members" && } {activeTab === "roles" && } {activeTab === "invitations" && } {activeTab === "audit" && } {activeTab === "settings" && }
); }; /* -------------------------------------------------------------------------- */ /* Upgrade / module-disabled cards */ /* -------------------------------------------------------------------------- */ const UpgradeCard: React.FC<{ meta: TeamMeta }> = ({ meta }) => (
{__("Team & Access", "yatra")} {__( "Available on the Scale plan. Add staff with role-appropriate access — front desk doesn't see refund history, guides see only their destinations, accountants get financial reports without booking edits.", "yatra", )}
{[ [ __("8 shipped roles + custom builder", "yatra"), __( "Owner, Manager, Sales Agent, Front Desk, Guide, Accountant, Marketing, Auditor.", "yatra", ), ], [ __("Per-user scope assignment", "yatra"), __("Restrict by destination, activity, or trip.", "yatra"), ], [ __("Magic-link invitations", "yatra"), __("Invite by email — no manual user creation.", "yatra"), ], [ __("180-day audit log", "yatra"), __("Every sensitive action recorded, exportable to CSV.", "yatra"), ], ].map(([title, sub]) => (
{title}
{sub}
))}
{meta.docs_url && ( )}
); const ModulePrompt: React.FC = () => ( {__("Enable the Team & Access module", "yatra")} {sprintf( /* translators: %s: brand name (e.g. "Yatra" or an operator's white-labeled brand) */ __( 'Toggle "Team & Access" on under %s → Modules to start configuring roles and members.', "yatra", ), brandName(), )} ); /* -------------------------------------------------------------------------- */ /* Time-windowed access helpers */ /* */ /* Operators set an "Access expires on" date on a member. Server stores a */ /* unix timestamp; UI uses `` which speaks */ /* `YYYY-MM-DDTHH:mm` in LOCAL time. These two helpers bridge formats and */ /* always round to the nearest minute (the picker doesn't show seconds). */ /* -------------------------------------------------------------------------- */ /** Convert a unix-seconds value into the local-input string the picker wants. */ const unixToLocalInputValue = (unixSec: number): string => { if (!unixSec || unixSec <= 0) return ""; const d = new Date(unixSec * 1000); // Build YYYY-MM-DDTHH:mm in LOCAL tz — Date constructor reads back the // same wall-clock time when the picker submits, which is what operators // intend ("expires at 5pm" means 5pm wherever they are). const pad = (n: number) => n.toString().padStart(2, "0"); return ( d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + "T" + pad(d.getHours()) + ":" + pad(d.getMinutes()) ); }; /** * Members-table cell that renders the access-expiry state. Three states: * * - permanent (expires_at === 0) * - expiring soon (1–7 days remaining): amber badge * - expired (timestamp in the past, but cron hasn't swept yet): red badge * - long-future (> 7 days): muted relative text */ const AccessExpiryCell: React.FC<{ user: TeamUser }> = ({ user }) => { if (!user.expires_at || user.expires_at <= 0) { return ( {__("Permanent", "yatra")} ); } const now = Math.floor(Date.now() / 1000); const secondsRemaining = user.expires_at - now; const daysRemaining = Math.ceil(secondsRemaining / 86400); const formatted = new Date(user.expires_at * 1000).toLocaleString(); if (secondsRemaining <= 0) { return ( {__("Expired", "yatra")} ); } if (daysRemaining <= 7) { return ( {sprintf( /* translators: %d: number of days */ __("Expires in %d day(s)", "yatra"), daysRemaining, )} ); } return ( {formatted} ); }; /** * Live "current time" caption shown under the expiry datetime-local input. * * Operators reach for `` without a frame of * reference for "now" — the picker doesn't show it, the browser doesn't * show it, and timezones vary. We display the local clock + a relative * offset for the picked value (e.g. "in 3 days") so they can sanity-check * before saving. * * Ticks once per minute. We don't tick per-second because the picker's * resolution is one minute — sub-minute updates are visual noise. */ const ExpiryNowHint: React.FC<{ expiresLocal: string }> = ({ expiresLocal, }) => { const [now, setNow] = useState(() => new Date()); React.useEffect(() => { const id = window.setInterval(() => setNow(new Date()), 60_000); return () => window.clearInterval(id); }, []); const nowFormatted = now.toLocaleString(); // Relative offset to the chosen expiry. Empty when nothing picked. let relative = ""; if (expiresLocal) { const target = new Date(expiresLocal).getTime(); const diffMs = target - now.getTime(); const absMin = Math.abs(Math.round(diffMs / 60_000)); if (diffMs < 0) { relative = __("(in the past)", "yatra"); } else if (absMin < 60) { relative = sprintf( /* translators: %d: minutes from now */ __("in %d min", "yatra"), absMin, ); } else if (absMin < 60 * 24) { relative = sprintf( /* translators: %d: hours from now */ __("in %d hour(s)", "yatra"), Math.round(absMin / 60), ); } else { relative = sprintf( /* translators: %d: days from now */ __("in %d day(s)", "yatra"), Math.round(absMin / (60 * 24)), ); } } return (
{sprintf( /* translators: %s: formatted current local datetime */ __("Current time: %s", "yatra"), nowFormatted, )} {relative && ( {sprintf( /* translators: %s: relative offset like "in 3 days" */ __("Expires %s", "yatra"), relative, )} )}
); }; /* -------------------------------------------------------------------------- */ /* Members tab */ /* -------------------------------------------------------------------------- */ const MembersTab: React.FC = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [editingId, setEditingId] = useState(null); const [pendingRemove, setPendingRemove] = useState(null); const [showAddModal, setShowAddModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false); /* ----- Bulk-ops selection state ----- */ /* `selectedIds` holds the *checked* user ids. `pendingBulk` holds the */ /* confirmation-modal state — null when no modal is open. */ const [selectedIds, setSelectedIds] = useState>(new Set()); const [pendingBulk, setPendingBulk] = useState(null); const { data, isLoading } = useQuery({ queryKey: ["team-users"], queryFn: () => teamApi.listUsers(), }); const { data: rolesData } = useQuery({ queryKey: ["team-roles"], queryFn: () => teamApi.listRoles(), }); const removeMutation = useMutation({ mutationFn: (id: number) => teamApi.removeUser(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["team-users"] }); showToast(__("Member removed.", "yatra"), "success"); setPendingRemove(null); }, onError: (e: any) => { showToast(extractError(e), "error"); setPendingRemove(null); }, }); const forceLogoutMutation = useMutation({ mutationFn: (id: number) => teamApi.forceLogout(id), onSuccess: () => showToast(__("All sessions invalidated.", "yatra"), "success"), onError: (e: any) => showToast(extractError(e), "error"), }); /* Bulk-ops mutation. Refreshes the member list + clears selection on */ /* success. Reports per-id failure count via toast (the response */ /* includes per-id results — we surface aggregate here, individual */ /* failure rows are visible by re-checking the table). */ const bulkMutation = useMutation({ mutationFn: (payload: Parameters[0]) => teamApi.bulkUsers(payload), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["team-users"] }); setSelectedIds(new Set()); setPendingBulk(null); if (res.data.fail_count > 0) { showToast( sprintf( /* translators: 1: ok count, 2: fail count */ __("Bulk done: %1$d succeeded, %2$d failed.", "yatra"), res.data.ok_count, res.data.fail_count, ), "warning", ); } else { showToast( res.message || __("Bulk action complete.", "yatra"), "success", ); } }, onError: (e: any) => { showToast(extractError(e), "error"); setPendingBulk(null); }, }); const users = data?.data ?? []; /* Toggle row selection. Skips the current user — they can never be the */ /* target of a destructive bulk op on themselves. */ const currentUserId = window.yatraAdmin?.currentUser; const toggleOne = (id: number, checked: boolean) => { setSelectedIds((prev) => { const next = new Set(prev); if (checked) next.add(id); else next.delete(id); return next; }); }; const toggleAll = (checked: boolean) => { if (!checked) { setSelectedIds(new Set()); return; } // Select every visible user except the operator themselves — // saves them a click and avoids implicit "include yourself" foot-gun. const next = new Set(); users.forEach((u) => { if (u.id !== currentUserId) next.add(u.id); }); setSelectedIds(next); }; const roleLabels = useMemo(() => { const map: Record = {}; (rolesData?.data ?? []).forEach((r) => { map[r.slug] = r.display_name; }); return map; }, [rolesData]); return (

{__("Team members", "yatra")}

{sprintf( /* translators: %s: brand name */ __( "Every WordPress user with a %s role. Add an existing WP user here, or send a magic-link invitation from the Invitations tab.", "yatra", ), brandName(), )}

{selectedIds.size > 0 && (
{sprintf( /* translators: %d: count of selected rows */ __("%d member(s) selected", "yatra"), selectedIds.size, )}
)} toggleOne(Number(id), checked)} onSelectAll={toggleAll} isAllSelected={ users.length > 0 && users .filter((u) => u.id !== currentUserId) .every((u) => selectedIds.has(u.id)) } getItemId={(u: TeamUser) => u.id} columns={[ { key: "display_name", label: __("Member", "yatra"), render: (u: TeamUser) => (
{(u.display_name || u.user_login) .charAt(0) .toUpperCase()}
{u.email}
{u.is_wp_admin && ( {__("WP Admin", "yatra")} )}
), }, { key: "primary_role", label: __("Role", "yatra"), render: (u: TeamUser) => u.primary_role ? ( {roleLabels[u.primary_role] || u.primary_role} ) : ( ), }, { key: "scope", label: __("Scope", "yatra"), render: (u: TeamUser) => u.has_scope ? ( {u.scopes.destinations.length > 0 && `${u.scopes.destinations.length} dest.`}{" "} {u.scopes.activities.length > 0 && `${u.scopes.activities.length} act.`}{" "} {u.scopes.trips.length > 0 && `${u.scopes.trips.length} trips`}{" "} {u.scopes.categories.length > 0 && `${u.scopes.categories.length} cat.`} } > {__("Scoped", "yatra")} ) : ( {__("Unrestricted", "yatra")} ), }, { key: "last_login", label: __("Last login", "yatra"), render: (u: TeamUser) => u.last_login ? ( {new Date(u.last_login).toLocaleString()} ) : ( ), }, { key: "expires_at", label: __("Access expires", "yatra"), render: (u: TeamUser) => , }, ]} actions={[ { key: "edit", label: __("Edit access", "yatra"), icon: , onClick: (u: TeamUser) => setEditingId(u.id), }, { key: "logout", label: __("Force logout", "yatra"), icon: , onClick: (u: TeamUser) => forceLogoutMutation.mutate(u.id), }, { key: "remove", label: __("Remove from team", "yatra"), icon: , onClick: (u: TeamUser) => setPendingRemove(u), variant: "destructive", }, ]} isLoading={isLoading} emptyText={__("No team members yet", "yatra")} emptyDescription={__( "Invite a teammate from the Invitations tab to get started.", "yatra", )} />
{editingId !== null && ( setEditingId(null)} /> )} {showAddModal && ( setShowAddModal(false)} /> )} {showCreateModal && ( setShowCreateModal(false)} /> )} !removeMutation.isPending && setPendingRemove(null)} onConfirm={() => pendingRemove && removeMutation.mutate(pendingRemove.id) } title={sprintf( /* translators: %s: brand name */ __("Remove member from %s?", "yatra"), brandName(), )} description={ pendingRemove ? sprintf( /* translators: 1: brand name, 2: member display name, 3: brand name */ __( 'This strips %1$s role + caps + scopes from "%2$s". Their WordPress user is preserved (they keep any non-%3$s access on this site).', "yatra", ), brandName(), pendingRemove.display_name, brandName(), ) : "" } confirmText={__("Remove access", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={removeMutation.isPending} /> {/* Bulk confirmation dialog. Copy varies by action; the operator */} {/* always sees the affected count + a destructive-style warning */} {/* when the action removes access or invalidates sessions. */} !bulkMutation.isPending && setPendingBulk(null)} onConfirm={() => { if (!pendingBulk) return; const ids = Array.from(selectedIds); if (pendingBulk.action === "change_role") { bulkMutation.mutate({ action: "change_role", user_ids: ids, role_slug: pendingBulk.roleSlug, }); } else if (pendingBulk.action === "remove") { bulkMutation.mutate({ action: "remove", user_ids: ids }); } else if (pendingBulk.action === "force_logout") { bulkMutation.mutate({ action: "force_logout", user_ids: ids }); } }} title={(() => { if (!pendingBulk) return ""; if (pendingBulk.action === "change_role") { return sprintf( /* translators: %d: count */ __("Change role on %d member(s)?", "yatra"), selectedIds.size, ); } if (pendingBulk.action === "remove") { return sprintf( /* translators: %d: count */ __("Remove %d member(s) from the team?", "yatra"), selectedIds.size, ); } return sprintf( /* translators: %d: count */ __("Force logout on %d member(s)?", "yatra"), selectedIds.size, ); })()} description={(() => { if (!pendingBulk) return ""; if (pendingBulk.action === "change_role") { const roleLabel = (rolesData?.data ?? []).find( (r) => r.slug === pendingBulk.roleSlug, )?.display_name ?? pendingBulk.roleSlug; return sprintf( /* translators: 1: role label, 2: count */ __( 'Sets the role to "%1$s" on %2$d member(s). Existing per-user grants and scopes are preserved. The last team administrator cannot be demoted — failures are reported per-id.', "yatra", ), roleLabel ?? "", selectedIds.size, ); } if (pendingBulk.action === "remove") { return __( "Strips role + caps + scopes from each selected member. Their WordPress user accounts stay. Last-team-admin is protected — that row will report as failed.", "yatra", ); } return __( "Invalidates every active session for the selected members. They will be logged out everywhere and need to re-authenticate.", "yatra", ); })()} confirmText={ pendingBulk?.action === "change_role" ? __("Apply role", "yatra") : pendingBulk?.action === "remove" ? __("Remove access", "yatra") : __("Force logout", "yatra") } cancelText={__("Cancel", "yatra")} variant={pendingBulk?.action === "remove" ? "danger" : "info"} isLoading={bulkMutation.isPending} />
); }; /* -------------------------------------------------------------------------- */ /* Add Member modal — attaches a Yatra role to an existing WP user. */ /* */ /* This is intentionally NOT the same as "Invite by email" (Invitations tab) */ /* — invitations create a brand-new WP user account. This flow is for users */ /* who already exist on the WP site but don't yet have Yatra access. */ /* -------------------------------------------------------------------------- */ const AddMemberModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [searchQ, setSearchQ] = useState(""); const [pickedId, setPickedId] = useState(null); const [roleSlug, setRoleSlug] = useState("yatra_sales_agent"); // Debounced search — only refetch when the user pauses typing. const [debouncedQ, setDebouncedQ] = useState(""); React.useEffect(() => { const t = window.setTimeout(() => setDebouncedQ(searchQ), 250); return () => window.clearTimeout(t); }, [searchQ]); const { data: candidatesData, isLoading: candidatesLoading } = useQuery({ queryKey: ["team-users-available", debouncedQ], queryFn: () => teamApi.listAvailableUsers(debouncedQ, 50), }); const { data: rolesData } = useQuery({ queryKey: ["team-roles"], queryFn: () => teamApi.listRoles(), }); const candidates = candidatesData?.data ?? []; const addMutation = useMutation({ mutationFn: () => teamApi.updateUser(pickedId!, { role_slug: roleSlug }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["team-users"] }); queryClient.invalidateQueries({ queryKey: ["team-users-available"] }); showToast(__("Member added to the team.", "yatra"), "success"); onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); return ( {__("Add existing WordPress user", "yatra")} } size="md" footer={
} >
{sprintf( /* translators: %s: brand name */ __( "This picker shows WordPress users who don't yet have a %s role. For people who aren't on the site at all, send them an email invitation from the Invitations tab — that creates the WP user for them.", "yatra", ), brandName(), )}
{ setSearchQ(e.target.value); setPickedId(null); }} placeholder={__("Search by name, email, or login…", "yatra")} className="mt-1" />
{candidatesLoading ? (
{[0, 1, 2, 3].map((i) => (
))}
) : candidates.length === 0 ? (
{debouncedQ === "" ? sprintf( /* translators: %s: brand name */ __( "No available users — every WP user on this site is already a %s team member. Use the Invitations tab to add new people.", "yatra", ), brandName(), ) : __("No matching users.", "yatra")}
) : (
{candidates.map((u) => ( ))}
)}

{__( "Per-user scopes + capability overrides can be set after the member is added (3-dot menu → Edit access).", "yatra", )}

); }; /* -------------------------------------------------------------------------- */ /* Create User modal — provisions a brand-new WP user + Yatra role in one go */ /* */ /* Sister flow to AddMemberModal (which attaches a role to an existing WP */ /* user) and InvitationsTab (which sends a magic-link email). This one is */ /* for operators who want to create the account directly — e.g. internal */ /* staff, contractors with no inbox we need to involve, batch onboarding. */ /* */ /* Password handling: operator chooses between "I'll set it now" (8+ chars) */ /* and "Send reset-password email" (recommended — operator never types/ */ /* shares the password). The latter triggers `wp_mail` reset flow. */ /* -------------------------------------------------------------------------- */ const CreateUserModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const { data: rolesData } = useQuery({ queryKey: ["team-roles"], queryFn: () => teamApi.listRoles(), }); const [email, setEmail] = useState(""); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [username, setUsername] = useState(""); const [roleSlug, setRoleSlug] = useState(""); type PwMode = "reset_email" | "manual"; const [pwMode, setPwMode] = useState("reset_email"); const [password, setPassword] = useState(""); // Email validation — RFC-flavored, good enough for UI. Server is the // source of truth (wp's `is_email`). const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); const passwordOk = pwMode === "reset_email" || password.length >= 8; const canSubmit = emailValid && roleSlug !== "" && passwordOk; const createMutation = useMutation({ mutationFn: () => teamApi.createUser({ email, role_slug: roleSlug, first_name: firstName || undefined, last_name: lastName || undefined, username: username || undefined, password: pwMode === "manual" ? password : undefined, send_reset_email: pwMode === "reset_email", }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["team-users"] }); showToast(__("User created and added to the team.", "yatra"), "success"); onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); return ( {__("Create new user", "yatra")} } size="lg" footer={
} >
{sprintf( /* translators: %s: brand name */ __( 'This provisions a brand-new WP account and attaches the chosen %s role in one step. For people who already have a WP user, use "Add existing WP user" instead. To send a magic-link invite via email (account is created on accept), use the Invitations tab.', "yatra", ), brandName(), )}
setFirstName(e.target.value)} className="mt-1" />
setLastName(e.target.value)} className="mt-1" />
setEmail(e.target.value)} placeholder="user@example.com" className="mt-1" required /> {email !== "" && !emailValid && (

{__("Enter a valid email address.", "yatra")}

)}
setUsername(e.target.value)} placeholder={__("Auto-generated from email", "yatra")} className="mt-1" />

{__( "WP requires unique, lowercase, no spaces. If left blank, derived from the email local-part.", "yatra", )}

); }; /* -------------------------------------------------------------------------- */ /* Member edit drawer — role + scopes + per-user caps + sessions */ /* -------------------------------------------------------------------------- */ const MemberEditDrawer: React.FC<{ userId: number; onClose: () => void; }> = ({ userId, onClose }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const { data, isLoading } = useQuery({ queryKey: ["team-user", userId], queryFn: () => teamApi.getUser(userId), }); const { data: rolesData } = useQuery({ queryKey: ["team-roles"], queryFn: () => teamApi.listRoles(), }); const { data: capsData } = useQuery({ queryKey: ["team-capabilities"], queryFn: () => teamApi.listCapabilities(), }); const user = data?.data; const [roleSlug, setRoleSlug] = useState(""); const [extraGrants, setExtraGrants] = useState([]); const [extraRevokes, setExtraRevokes] = useState([]); // Time-windowed access. Stored as `YYYY-MM-DDTHH:mm` for . // Empty string means "permanent" — the mutation omits the field when empty // OR sends 0 when the user explicitly clicks "Clear expiry". const [expiresLocal, setExpiresLocal] = useState(""); // True when the user touched the expiry field this session — only then // do we send `expires_at` to the server. Lets us distinguish "operator // didn't touch this" from "operator explicitly cleared it". const [expiryDirty, setExpiryDirty] = useState(false); React.useEffect(() => { if (user) { setRoleSlug(user.primary_role || ""); setExtraGrants(user.caps_grant); setExtraRevokes(user.caps_revoke); setExpiresLocal(unixToLocalInputValue(user.expires_at)); setExpiryDirty(false); } }, [user]); const isSelfEdit = user?.id === window.yatraAdmin?.currentUser; const updateMutation = useMutation({ mutationFn: () => { const payload: TeamUserWritePayload = { role_slug: roleSlug, caps_grant: extraGrants, caps_revoke: extraRevokes, }; if (expiryDirty) { // Send 0 when cleared (= permanent), otherwise unix seconds. payload.expires_at = expiresLocal ? Math.floor(new Date(expiresLocal).getTime() / 1000) : 0; } return teamApi.updateUser(userId, payload); }, onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["team-users"] }); queryClient.invalidateQueries({ queryKey: ["team-user", userId] }); // If the operator edited their OWN account, refresh the // current-user cap cache so the UI updates without reload. if (res.data?.id === (window.yatraAdmin?.userCaps ? userId : -1)) { setUserCaps(res.data.effective_caps); } showToast(__("Member updated.", "yatra"), "success"); onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); return ( {user ? user.display_name : __("Loading…", "yatra")} } size="lg" hideFooter={false} footer={
{/* Hide Save when the target is a WP admin — nothing to */} {/* persist (role / caps / scopes / expiry are all no-ops */} {/* against the admin fallback and the server would 409). */} {!user?.is_wp_admin && ( )}
} > {isLoading || !user ? (
{/* Skeleton mirroring the loaded drawer layout: role select, */} {/* expiry box, cap matrix groups. Same heights as the real form */} {/* so the modal doesn't jump on data arrival. */}
{[0, 1, 2].map((i) => (
))}
) : (
{user.is_wp_admin ? ( // Admin-lock UI. WP administrators always pass every yatra_* // cap via the server-side admin fallback, so role / grant / // revoke / scope / expiry assignments against them would be // misleading no-ops. We show a read-only summary instead of // editable form controls, and the server rejects any write // attempts with `yatra_team_admin_locked` (409) as a defense- // in-depth check.

{sprintf( /* translators: %s: brand name */ __( "This user is a WP administrator and always passes every %s capability check via the admin fallback. Role assignment, capability grants, revokes, scope restrictions, and access expiry cannot be enforced on them.", "yatra", ), brandName(), )}

{__( "To scope this user's Yatra access, first remove their WordPress administrator role from the standard wp-admin Users screen, then return here to assign a Yatra role.", "yatra", )}

{/* Read-only summary of what they DO have — useful for */} {/* audit / "why does this person see X" conversations. */}
{__("Effective capabilities", "yatra")}
{sprintf( /* translators: %d: count of caps */ __("%d (all yatra_*)", "yatra"), user.effective_caps.length, )}
{__("Yatra role on record", "yatra")}
{user.primary_role ? roleSlug || user.primary_role : __("None (admin fallback only)", "yatra")}
) : ( <>
{/* Time-windowed access. Hidden when: */ /* - operator is editing themselves (server blocks self-expiry) */ /* - target is a WP admin (admin fallback makes expiry a no-op) */} {!isSelfEdit && !user.is_wp_admin && (

{__( "Time-windowed access: caps are revoked automatically once this passes. Useful for contractors, seasonal staff, or temporary vendor access. Leave blank for permanent access.", "yatra", )}

{expiresLocal && ( )}
{ setExpiresLocal(e.target.value); setExpiryDirty(true); }} className="w-full" /> {user.is_expired && ( {__( "This member is past their expiry and currently has no access. The next hourly sweep will fully strip their role + grants. Set a new date above to extend access — or clear the expiry to make it permanent.", "yatra", )} )}
)} {capsData && ( )} )}
)}
); }; const CapabilityOverridesEditor: React.FC<{ registry: Record; effective: string[]; grants: string[]; revokes: string[]; onChangeGrants: (next: string[]) => void; onChangeRevokes: (next: string[]) => void; }> = ({ registry, effective, grants, revokes, onChangeGrants, onChangeRevokes, }) => { const byCategory = useMemo(() => { const map: Record> = {}; Object.entries(registry).forEach(([cap, def]) => { if (!map[def.category]) map[def.category] = []; map[def.category].push([cap, def]); }); return map; }, [registry]); const sensColor = (s: string): string => { if (s === "critical") return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"; if (s === "high") return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"; if (s === "medium") return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"; return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"; }; return (
{__("Per-user capability overrides", "yatra")}

{__( "Override the role's defaults for THIS user. Grants extend access; revokes deny even when the role allows.", "yatra", )}

{Object.entries(byCategory).map(([cat, rows]) => (
{cat}
{rows.map(([cap, def]) => { const isEffective = effective.includes(cap); const isGranted = grants.includes(cap); const isRevoked = revokes.includes(cap); return (
{def.label} {def.sensitivity}
{cap}
{isEffective && !isGranted && !isRevoked && ( {__("via role", "yatra")} )}
); })}
))}
); }; /* -------------------------------------------------------------------------- */ /* Roles tab */ /* -------------------------------------------------------------------------- */ const RolesTab: React.FC = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [editingRoleSlug, setEditingRoleSlug] = useState(null); // "new" sentinel = create-flow with empty bundle. // "clone:" sentinel = create-flow seeded with that role's caps. const [createSeed, setCreateSeed] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); const { data, isLoading } = useQuery({ queryKey: ["team-roles"], queryFn: () => teamApi.listRoles(), }); const deleteMutation = useMutation({ mutationFn: (slug: string) => teamApi.deleteRole(slug), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["team-roles"] }); showToast(__("Role deleted.", "yatra"), "success"); setPendingDelete(null); }, onError: (e: any) => { showToast(extractError(e), "error"); setPendingDelete(null); }, }); const roles = data?.data ?? []; return (

{__("Roles", "yatra")}

{__( "Eight shipped system roles cover most agencies. Click any role to see + edit its capabilities. Clone or create your own with the buttons here.", "yatra", )}

(
{r.slug}
), }, { key: "is_system", label: __("Type", "yatra"), render: (r: TeamRole) => r.is_system ? ( {__("System", "yatra")} ) : ( {__("Custom", "yatra")} ), }, { key: "capability_count", label: __("Capabilities", "yatra"), render: (r: TeamRole) => ( setEditingRoleSlug(r.slug)} > {r.capability_count} {__("caps", "yatra")} ), }, { key: "member_count", label: __("Members", "yatra"), render: (r: TeamRole) => ( {r.member_count} ), }, ]} actions={[ { key: "edit", label: __("View capabilities", "yatra"), icon: , onClick: (r: TeamRole) => setEditingRoleSlug(r.slug), }, { key: "clone", label: __("Clone to custom role", "yatra"), icon: , onClick: (r: TeamRole) => setCreateSeed(`clone:${r.slug}`), }, { key: "delete", label: __("Delete role", "yatra"), icon: , onClick: (r: TeamRole) => setPendingDelete(r), condition: (r: TeamRole) => !r.is_system, variant: "destructive", }, ]} isLoading={isLoading} emptyText={__("No roles found", "yatra")} emptyDescription={__( "System roles should have populated automatically. Try toggling the module off and on.", "yatra", )} />
{editingRoleSlug !== null && ( setEditingRoleSlug(null)} onClone={(slug) => { setEditingRoleSlug(null); setCreateSeed(`clone:${slug}`); }} /> )} {createSeed !== null && ( setCreateSeed(null)} /> )} !deleteMutation.isPending && setPendingDelete(null)} onConfirm={() => pendingDelete && deleteMutation.mutate(pendingDelete.slug) } title={__("Delete custom role?", "yatra")} description={ pendingDelete ? __( 'Members assigned to "{name}" will lose this role. Their WordPress user account is preserved.', "yatra", ).replace("{name}", pendingDelete.display_name) : "" } confirmText={__("Delete role", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={deleteMutation.isPending} />
); }; /* -------------------------------------------------------------------------- */ /* Role edit drawer — shows every capability the role has, grouped by */ /* category with sensitivity badges. System roles render read-only with a */ /* "Clone to edit" button; custom roles get an editable matrix + save. */ /* -------------------------------------------------------------------------- */ const RoleEditDrawer: React.FC<{ slug: string; onClose: () => void; onClone: (slug: string) => void; }> = ({ slug, onClose, onClone }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const { data, isLoading } = useQuery({ queryKey: ["team-role", slug], queryFn: () => teamApi.getRole(slug), }); const { data: capsData } = useQuery({ queryKey: ["team-capabilities"], queryFn: () => teamApi.listCapabilities(), }); const role = data?.data; const [displayName, setDisplayName] = useState(""); const [selectedCaps, setSelectedCaps] = useState([]); React.useEffect(() => { if (role) { setDisplayName(role.display_name); setSelectedCaps(role.capabilities); } }, [role]); const updateMutation = useMutation({ mutationFn: () => teamApi.updateRole(slug, { display_name: displayName, capabilities: selectedCaps, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["team-roles"] }); queryClient.invalidateQueries({ queryKey: ["team-role", slug] }); showToast(__("Role updated.", "yatra"), "success"); onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); const isSystem = role?.is_system ?? false; return ( {role ? role.display_name : __("Loading…", "yatra")} {isSystem && ( {__("System role", "yatra")} )} } size="lg" hideFooter={false} footer={
{isSystem ? ( ) : ( )}
} > {isLoading || !role ? (
{/* Skeleton mirrors the loaded role-edit form: name input + */} {/* category-grouped cap matrix. Same shape so the dialog */} {/* doesn't reflow when data arrives. */}
{[0, 1, 2, 3].map((i) => (
))}
) : (
{isSystem && ( {sprintf( /* translators: 1: brand name, 2: brand name */ __( "System roles ship with %1$s and can't be edited directly — clone to a custom role to change capabilities. This protects your team if %2$s ships new capabilities in future releases (clones won't auto-update; system roles will).", "yatra", ), brandName(), brandName(), )} )}
setDisplayName(e.target.value)} disabled={isSystem} className="mt-1" />

{role.slug} ·{" "} {role.member_count}{" "} {role.member_count === 1 ? __("member", "yatra") : __("members", "yatra")}

{capsData && ( )}
)}
); }; /* -------------------------------------------------------------------------- */ /* Role create drawer — same UX as edit but starts from an empty bundle */ /* (or seeded from a system role when the operator clicked "Clone"). */ /* -------------------------------------------------------------------------- */ const RoleCreateDrawer: React.FC<{ seedSlug: string | null; onClose: () => void; }> = ({ seedSlug, onClose }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const { data: seedData } = useQuery({ queryKey: ["team-role", seedSlug], queryFn: () => teamApi.getRole(seedSlug!), enabled: seedSlug !== null, }); const { data: capsData } = useQuery({ queryKey: ["team-capabilities"], queryFn: () => teamApi.listCapabilities(), }); // Server-curated role templates — fetched once. Empty when there // are no templates configured (e.g. an operator-side filter wiped // the list). const { data: templatesData } = useQuery({ queryKey: ["team-role-templates"], queryFn: () => teamApi.listRoleTemplates(), enabled: seedSlug === null, // only show templates for fresh creation }); const [displayName, setDisplayName] = useState(""); const [selectedCaps, setSelectedCaps] = useState([]); const [appliedTemplateId, setAppliedTemplateId] = useState( null, ); React.useEffect(() => { if (seedData?.data) { setDisplayName(`${seedData.data.display_name} (copy)`); setSelectedCaps(seedData.data.capabilities); } }, [seedData]); /** * Apply a template. Sets the cap list to the template's caps + always * appends the umbrella `yatra_access_admin` so the role can see the * Yatra menu (otherwise the role is functionally useless on day 1). * Name stays as whatever the operator typed — they likely picked the * template AFTER typing. */ const applyTemplate = (templateId: string) => { const tpl = (templatesData?.data ?? []).find((t) => t.id === templateId); if (!tpl) return; const caps = Array.from( new Set([...tpl.capabilities, "yatra_access_admin"]), ); setSelectedCaps(caps); setAppliedTemplateId(templateId); if (displayName.trim() === "") { setDisplayName(tpl.label); } }; const createMutation = useMutation({ mutationFn: () => teamApi.createRole({ display_name: displayName, capabilities: selectedCaps, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["team-roles"] }); showToast(__("Custom role created.", "yatra"), "success"); onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); const canSave = displayName.trim() !== "" && !createMutation.isPending; return ( {seedSlug ? __("Clone role", "yatra") : __("Create custom role", "yatra")} } size="lg" footer={
} >
setDisplayName(e.target.value)} placeholder={__("e.g. Senior Sales Agent", "yatra")} className="mt-1" />

{__( "Slug auto-generated from the name (yatra_* prefix added).", "yatra", )}

{/* Template picker — only when creating fresh (clone path skips it). */} {seedSlug === null && (templatesData?.data?.length ?? 0) > 0 && (

{__( "Pre-curated cap bundles for common archetypes. Picking one prefills the matrix below — fully editable before save.", "yatra", )}

{appliedTemplateId && ( )}
{(templatesData?.data ?? []).map((tpl) => { const active = appliedTemplateId === tpl.id; return ( ); })}
)} {capsData && ( )}
); }; /* -------------------------------------------------------------------------- */ /* Capability matrix — categorized checklist of every Yatra cap. */ /* Reused by RoleEditDrawer + RoleCreateDrawer. Read-only when `disabled`. */ /* -------------------------------------------------------------------------- */ const CapabilityMatrix: React.FC<{ registry: Record; selected: string[]; onChange: (next: string[]) => void; disabled: boolean; }> = ({ registry, selected, onChange, disabled }) => { const byCategory = useMemo(() => { const map: Record> = {}; Object.entries(registry).forEach(([cap, def]) => { if (!map[def.category]) map[def.category] = []; map[def.category].push([cap, def]); }); return map; }, [registry]); const selectedSet = useMemo(() => new Set(selected), [selected]); const sensColor = (s: string): string => { if (s === "critical") return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"; if (s === "high") return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"; if (s === "medium") return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"; return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"; }; const toggleCap = (cap: string) => { if (disabled) return; if (selectedSet.has(cap)) { onChange(selected.filter((c) => c !== cap)); } else { onChange([...selected, cap]); } }; const toggleCategory = (caps: Array<[string, CapabilityDef]>) => { if (disabled) return; const ids = caps.map(([c]) => c); const allSelected = ids.every((id) => selectedSet.has(id)); if (allSelected) { onChange(selected.filter((c) => !ids.includes(c))); } else { const next = new Set(selected); ids.forEach((c) => next.add(c)); onChange(Array.from(next)); } }; return (
{__("Capabilities", "yatra")}

{disabled ? __( "Read-only — system roles can't be edited. Use Clone to make an editable copy.", "yatra", ) : __( "Each capability gates a specific action. Sensitivity drives audit-log defaults.", "yatra", )}

{selected.length} / {Object.keys(registry).length}
{Object.entries(byCategory).map(([category, rows]) => { const ids = rows.map(([c]) => c); const checked = ids.filter((c) => selectedSet.has(c)).length; const allChecked = checked === ids.length; return (
{category}{" "} ({checked}/{ids.length})
{!disabled && ( )}
{rows.map(([cap, def]) => { const isChecked = selectedSet.has(cap); return ( ); })}
); })}
); }; /* -------------------------------------------------------------------------- */ /* Invitations tab */ /* -------------------------------------------------------------------------- */ const InvitationsTab: React.FC = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [showInviteModal, setShowInviteModal] = useState(false); const [revealAcceptUrl, setRevealAcceptUrl] = useState(null); // Revoke flow uses a confirmation dialog so the operator can opt // in to also purging the record. A simple one-click revoke would // leave a stale `revoked` row in the table forever. const [pendingRevoke, setPendingRevoke] = useState( null, ); const [revokeAlsoDelete, setRevokeAlsoDelete] = useState(true); // Standalone delete for non-pending rows (revoked / accepted / expired). // Confirms before purging so cleanup is intentional. const [pendingDelete, setPendingDelete] = useState( null, ); const { data, isLoading } = useQuery({ queryKey: ["team-invitations"], queryFn: () => teamApi.listInvitations(), }); const revokeMutation = useMutation({ mutationFn: (vars: { id: string; purge: boolean }) => teamApi.revokeInvitation(vars.id, { purge: vars.purge }), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["team-invitations"] }); // Mirror the audit log entry into the audit-tab cache too so // a freshly-open Audit tab reflects the new event. queryClient.invalidateQueries({ queryKey: ["team-audit"] }); showToast(res.message, "success"); setPendingRevoke(null); setPendingDelete(null); }, onError: (e: any) => { showToast(extractError(e), "error"); setPendingRevoke(null); setPendingDelete(null); }, }); const rows = useMemo(() => { const map = data?.data ?? {}; return Object.values(map); }, [data]); return (

{__("Invitations", "yatra")}

{__( "Magic-link invitations expire after 72 hours by default. Tokens are stored hashed; the link is only shown once.", "yatra", )}

(
{i.email}
), }, { key: "role_slug", label: __("Role", "yatra"), render: (i: TeamInvitation) => ( {i.role_slug} ), }, { key: "status", label: __("Status", "yatra"), render: (i: TeamInvitation) => { const cls: Record = { pending: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", accepted: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", revoked: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300", expired: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", }; return {i.status}; }, }, { key: "expires_at", label: __("Expires", "yatra"), render: (i: TeamInvitation) => ( {new Date(i.expires_at * 1000).toLocaleString()} ), }, ]} actions={[ // Revoke — only meaningful for a still-pending invitation. // Opens a confirmation that lets the operator opt-in to // ALSO deleting the row (default checked — most operators // don't want orphaned `revoked` rows piling up). { key: "revoke", label: __("Revoke", "yatra"), icon: , onClick: (i: TeamInvitation) => { setRevokeAlsoDelete(true); setPendingRevoke(i); }, condition: (i: TeamInvitation) => i.status === "pending", variant: "destructive", }, // Delete — for any non-pending row (revoked, accepted, // expired). Pure storage hygiene. Without this, the // table grew without bound as old invitations accumulated. { key: "delete", label: __("Delete record", "yatra"), icon: , onClick: (i: TeamInvitation) => setPendingDelete(i), condition: (i: TeamInvitation) => i.status !== "pending", variant: "destructive", }, ]} isLoading={isLoading} emptyText={__("No invitations yet", "yatra")} emptyDescription={__( "Send an invitation to add a teammate.", "yatra", )} onCreateClick={() => setShowInviteModal(true)} />
{showInviteModal && ( setShowInviteModal(false)} onSent={(acceptUrl) => { setShowInviteModal(false); setRevealAcceptUrl(acceptUrl); }} /> )} setRevealAcceptUrl(null)} /> {/* Revoke confirmation. Opt-in checkbox to also purge the row */} {/* (default ON — most operators don't want orphaned `revoked` */} {/* rows piling up over time). */} {pendingRevoke && ( { if (!revokeMutation.isPending) setPendingRevoke(null); }} title={__("Revoke invitation?", "yatra")} size="md" >

{sprintf( /* translators: %s: invited email address */ __( "The magic-link sent to %s will stop working immediately. Anyone who already clicked the link before now has already accepted (you can confirm in the audit log).", "yatra", ), pendingRevoke.email, )}

)} {/* Standalone delete — for already-terminal rows. */} { if (!revokeMutation.isPending) setPendingDelete(null); }} onConfirm={() => { if (pendingDelete) { revokeMutation.mutate({ id: pendingDelete.id, purge: true }); } }} title={__("Delete invitation record?", "yatra")} description={ pendingDelete ? sprintf( /* translators: 1: invited email address, 2: current status */ __( "Permanently remove the %1$s invitation (status: %2$s) from this list. The audit log entry stays, so you can still see who was invited and when — only the live record is removed.", "yatra", ), pendingDelete.email, pendingDelete.status, ) : "" } confirmText={__("Delete record", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={revokeMutation.isPending} />
); }; const InvitationModal: React.FC<{ onClose: () => void; onSent: (acceptUrl: string) => void; }> = ({ onClose, onSent }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const { data: rolesData } = useQuery({ queryKey: ["team-roles"], queryFn: () => teamApi.listRoles(), }); const [email, setEmail] = useState(""); const [role, setRole] = useState("yatra_sales_agent"); const [expiresIn, setExpiresIn] = useState(259200); const sendMutation = useMutation({ mutationFn: () => teamApi.sendInvitation({ email, role, expires_in: expiresIn }), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["team-invitations"] }); showToast(__("Invitation sent.", "yatra"), "success"); onSent(res.data.accept_url); }, onError: (e: any) => showToast(extractError(e), "error"), }); return ( {__("Invite a team member", "yatra")} } size="md" footer={
} >
setEmail(e.target.value)} placeholder="teammate@example.com" className="mt-1" />
{__( "The invitee receives an email with a magic link. Clicking it attaches the chosen role to an existing WP user with that email, or creates a new WP user + sends them a password-reset link.", "yatra", )}
); }; const AcceptUrlRevealDialog: React.FC<{ url: string | null; onClose: () => void; }> = ({ url, onClose }) => { const [copied, setCopied] = useState(false); const doCopy = async () => { if (!url) return; try { await navigator.clipboard.writeText(url); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch (_e) { /* no-op */ } }; if (!url) return null; return ( {__("Invitation link", "yatra")} } size="md" footer={
} >

{__( "The invitation email is on its way. If you'd like to share the link manually (e.g. via Slack), copy it below — it's only shown once.", "yatra", )}

e.currentTarget.select()} className="font-mono text-xs" aria-label={__("Accept URL", "yatra")} />
{__( "Anyone with this URL can claim the invited role until it expires.", "yatra", )}
); }; /* -------------------------------------------------------------------------- */ /* Audit log tab */ /* -------------------------------------------------------------------------- */ const AuditLogTab: React.FC = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [page, setPage] = useState(1); const [actionFilter, setActionFilter] = useState(""); const [entityFilter, setEntityFilter] = useState(""); const [resultFilter, setResultFilter] = useState(""); // Selection + confirm-modal state for bulk delete + clear-all. // selectedIds is reset on filter / page change so an operator // can't accidentally carry a stale selection across views. const [selectedIds, setSelectedIds] = useState>(new Set()); const [confirmClear, setConfirmClear] = useState(false); const [confirmBulk, setConfirmBulk] = useState(false); const perPage = 50; const { data, isLoading } = useQuery({ queryKey: [ "team-audit-log", page, actionFilter, entityFilter, resultFilter, ], queryFn: () => teamApi.listAuditLog({ page, per_page: perPage, ...(actionFilter ? { action: actionFilter } : {}), ...(entityFilter ? { entity_type: entityFilter } : {}), ...(resultFilter ? { result: resultFilter as "allowed" | "denied" } : {}), }), placeholderData: (prev) => prev, }); const { data: facets } = useQuery({ queryKey: ["team-audit-facets"], queryFn: () => teamApi.auditFacets(), }); const rows = data?.data ?? []; const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / perPage)); const hasFilters = !!(actionFilter || entityFilter || resultFilter); const invalidateAudit = () => { queryClient.invalidateQueries({ queryKey: ["team-audit-log"] }); queryClient.invalidateQueries({ queryKey: ["team-audit-facets"] }); setSelectedIds(new Set()); }; const clearMutation = useMutation({ mutationFn: () => teamApi.clearAuditLog(), onSuccess: (res) => { invalidateAudit(); setConfirmClear(false); showToast(res.message, "success"); }, onError: (e: any) => { setConfirmClear(false); showToast(extractError(e), "error"); }, }); const bulkDeleteMutation = useMutation({ mutationFn: (ids: number[]) => teamApi.bulkDeleteAuditLog(ids), onSuccess: (res) => { invalidateAudit(); setConfirmBulk(false); showToast(res.message, "success"); }, onError: (e: any) => { setConfirmBulk(false); showToast(extractError(e), "error"); }, }); // Reset selection whenever the filter / page changes so it never // points at rows the operator can't see. React.useEffect(() => { setSelectedIds(new Set()); }, [page, actionFilter, entityFilter, resultFilter]); const toggleOne = (id: number, checked: boolean) => { setSelectedIds((prev) => { const next = new Set(prev); if (checked) next.add(id); else next.delete(id); return next; }); }; const toggleAll = (checked: boolean) => { if (!checked) { setSelectedIds(new Set()); return; } setSelectedIds(new Set(rows.map((r: any) => r.id as number))); }; const isAllSelected = rows.length > 0 && rows.every((r: any) => selectedIds.has(r.id)); return (

{__("Audit log", "yatra")}

{__( "Append-only event stream. High + critical sensitivity actions and every denied attempt are logged. Default retention 180 days.", "yatra", )}

{/* Clear-all is always available (independent of selection) so */} {/* operators can wipe the whole log without scrolling pages. */} {/* The destructive action confirms via dialog + writes a final */} {/* audit entry recording who triggered the clear. */} {total > 0 && ( )}
{/* Bulk-action bar — only shown when at least one row is checked. */} {selectedIds.size > 0 && (
{sprintf( /* translators: %d: count of selected audit-log entries */ __("%d entries selected", "yatra"), selectedIds.size, )}
)}
{hasFilters && ( )}
toggleOne(Number(id), checked)} onSelectAll={toggleAll} isAllSelected={isAllSelected} getItemId={(r: any) => r.id} columns={[ { key: "occurred_at", label: __("When", "yatra"), render: (r: any) => ( {new Date(r.occurred_at).toLocaleString()} ), }, { key: "actor", label: __("Who", "yatra"), render: (r: any) => r.actor_user_id ? (
{r.actor_display_name || `#${r.actor_user_id}`}
{r.actor_ip && (
{r.actor_ip}
)}
) : ( {__("system", "yatra")} ), }, { key: "action", label: __("Action", "yatra"), render: (r: any) => ( {r.action} ), }, { key: "entity", label: __("Entity", "yatra"), render: (r: any) => r.entity_type ? ( {r.entity_type} {r.entity_id ? ` #${r.entity_id}` : ""} ) : ( ), }, { key: "result", label: __("Result", "yatra"), render: (r: any) => r.result === "denied" ? ( {__("Denied", "yatra")} ) : ( {__("Allowed", "yatra")} ), }, ]} actions={[]} isLoading={isLoading} emptyText={__("No audit events yet", "yatra")} emptyDescription={__( "High and critical sensitivity actions will appear here as they happen.", "yatra", )} /> {rows.length > 0 && totalPages > 1 && (
)}
{/* Clear-all confirmation. Destructive variant + explicit warning */} {/* that the operator's own clear-action will be the only entry */} {/* surviving in the new log. */} !clearMutation.isPending && setConfirmClear(false)} onConfirm={() => clearMutation.mutate()} title={__("Clear the entire audit log?", "yatra")} description={sprintf( /* translators: %d: count of audit-log entries about to be wiped */ __( "This permanently deletes all %d audit-log entries. The clear action itself will be recorded as a new entry — so the operator who wiped history is documented. There is no undo.", "yatra", ), total, )} confirmText={__("Yes, clear all", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={clearMutation.isPending} /> {/* Bulk-delete confirmation. Shows the selected-row count + that */} {/* deletion is permanent and audit-logged. */} !bulkDeleteMutation.isPending && setConfirmBulk(false)} onConfirm={() => bulkDeleteMutation.mutate(Array.from(selectedIds))} title={sprintf( /* translators: %d: count of selected audit-log entries */ __("Delete %d audit-log entries?", "yatra"), selectedIds.size, )} description={__( "Permanent. The deletion itself is recorded as a new audit entry so the operator who removed history is documented.", "yatra", )} confirmText={__("Delete entries", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={bulkDeleteMutation.isPending} />
); }; /* -------------------------------------------------------------------------- */ /* SettingsTab */ /* */ /* Module-level settings for Team & Access. Currently a single forward- */ /* looking toggle: what happens to existing team access if the operator */ /* ever turns the module off? */ /* */ /* When the module is ON (which is the only time this tab is reachable), */ /* every assigned role just works. The toggle is purely about post-disable */ /* behavior — set it now, and it kicks in if/when the module is disabled. */ /* -------------------------------------------------------------------------- */ const SettingsTab: React.FC = () => { const queryClient = useQueryClient(); const { showToast } = useToast(); const { data, isLoading } = useQuery({ queryKey: ["team-settings"], queryFn: () => teamApi.getSettings(), }); const keepAccess = data?.data?.keep_access_on_module_disable === true; // IP allowlist for staff (yatra_* role) logins. Empty = no // restriction. The textarea is bound to a local draft so the operator // can edit without firing a save on every keystroke; we save on blur // OR explicit click. const serverAllowlist = data?.data?.login_ip_allowlist ?? ""; const [allowlistDraft, setAllowlistDraft] = React.useState(serverAllowlist); React.useEffect(() => { setAllowlistDraft(serverAllowlist); }, [serverAllowlist]); const allowlistDirty = allowlistDraft.trim() !== serverAllowlist.trim(); const updateMutation = useMutation({ mutationFn: (next: boolean) => teamApi.updateSettings({ keep_access_on_module_disable: next }), onSuccess: (res) => { // Keep the server-injected snapshot in sync so any other code // reading window.yatraAdmin.teamKeepAccessOnModuleDisable gets // the fresh value without a page reload. if (typeof window !== "undefined" && window.yatraAdmin) { window.yatraAdmin.teamKeepAccessOnModuleDisable = res.data.keep_access_on_module_disable === true; } queryClient.invalidateQueries({ queryKey: ["team-settings"] }); // Audit log entry written by the server — invalidate so the // Audit log tab reflects the toggle on next visit. queryClient.invalidateQueries({ queryKey: ["team-audit"] }); showToast(res.message, "success"); }, onError: (e: any) => showToast(extractError(e), "error"), }); const saveAllowlist = useMutation({ mutationFn: (next: string) => teamApi.updateSettings({ login_ip_allowlist: next }), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["team-settings"] }); queryClient.invalidateQueries({ queryKey: ["team-audit"] }); showToast(res.message, "success"); // Re-sync the draft to whatever the server normalized to (so // invalid CIDRs the operator typed are visibly dropped). setAllowlistDraft(res.data.login_ip_allowlist ?? ""); }, onError: (e: any) => showToast(extractError(e), "error"), }); if (isLoading) { return (
); } return (

{__("Team & Access settings", "yatra")}

{__( "One setting: decide what happens to your team's access if you ever turn off this module. Every change here is recorded in the Audit log.", "yatra", )}

{/* Top notice — explains the current state + the trade-off in */} {/* plain English BEFORE the operator touches the toggle. The */} {/* default is OFF (security-conservative: revoke on disable). */} {/* When the operator flips it ON they've opted in to preserving */} {/* team access when the module is later disabled. */} {keepAccess ? ( {__( "This toggle is ON. If you ever turn off the Team & Access module, your team members keep their access and their Yatra roles stay on your site. This is a permissive choice — only leave it ON if you specifically want roles to survive a module-off period (for example, a brief maintenance window).", "yatra", )} ) : ( {__( "This toggle is OFF, which is the default. If you ever turn off the Team & Access module, every Yatra role on your site (Owner, Manager, Sales Agent, and any custom roles you built) will be removed, and your team members will lose all Yatra access. Re-enabling the module brings back the 8 built-in roles, but your custom roles and the original assignments do NOT come back. Flip this toggle ON if you'd rather keep your team's access when the module is off.", "yatra", )} )} {/* Single forward-looking toggle. Switch on the right, plain- */} {/* language label + dynamic helper text on the left. */}
{keepAccess ? ( ) : ( )}

{keepAccess ? __( "ON. If you turn this module off later, your team members keep their roles and current access. Nothing on your site changes until you decide to switch back.", "yatra", ) : __( "OFF (default). If you turn this module off later, every Yatra role on your site will be removed and your team members will lose their Yatra access. You (the site owner) always keep your own admin access.", "yatra", )}

{updateMutation.isPending && (

{__("Saving…", "yatra")}

)}
updateMutation.mutate(next)} />
{/* Plain-language "what happens" matrix — covers both module */} {/* states so the operator can see how the setting plays out */} {/* across the lifecycle. */}
{__("While this module stays ON", "yatra")}
  • {__("You (site owner) see everything.", "yatra")}
  • {__("Team members see only what their role allows.", "yatra")}
  • {__( "Customers and other users see nothing in the admin.", "yatra", )}
  • {__( "This setting doesn't apply yet — it kicks in only if you turn the module off.", "yatra", )}
{keepAccess ? ( ) : ( )} {__("If you ever turn this module OFF", "yatra")}
  • {__("You (site owner) still see everything.", "yatra")}
  • {keepAccess ? ( <>
  • {__( "Team members keep their access — based on their assigned role.", "yatra", )}
  • {__( "Advanced features (expiry, per-user grants, scopes, audit log) pause until you turn the module back on.", "yatra", )}
  • ) : ( <>
  • {__( "All Yatra roles are deleted (Owner, Manager, custom roles you built — everything except the Yatra Customer role).", "yatra", )}
  • {__("Team members lose all their Yatra access.", "yatra")}
  • {__( "Re-enabling the module brings back the 8 built-in roles. Custom roles and the original member assignments don't come back automatically.", "yatra", )}
  • )}
{__( "Leave the toggle OFF (default) for the security-conservative behavior — every Yatra role is removed if the module is later disabled, so no stale role-based access lingers. Flip it ON only if you specifically need team access to survive a module-off period (e.g. a brief maintenance toggle). Your audit log and member data are preserved either way.", "yatra", )}
{/* Login IP allowlist — security feature for compliance */} {/* operators (SOX, ISO 27001 A.9.4.2, PCI DSS 8.1.5). Empty is */} {/* the safe default; operators opt in deliberately. The */} {/* WordPress admin (manage_options) is always exempt, so a */} {/* misconfigured list can never lock the site owner out. */} {__("Restrict staff logins by source IP", "yatra")} {__( "Optional. Only users who hold a Yatra role are affected — WordPress administrators are always exempt and can never lock themselves out. Drop a comma- or newline-separated list of CIDRs (e.g. 203.0.113.0/24, 198.51.100.5). Empty = no restriction.", "yatra", )}