fiddy/apps/web/components/group-settings-content.tsx

1026 lines
59 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import useTags from "@/features/tags/hooks/use-tags";
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useGroupMembers from "@/features/groups/hooks/use-group-members";
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
import { useGroupsContext } from "@/hooks/groups-context";
import TagInput from "@/components/tag-input";
import { useNotificationsContext } from "@/hooks/notifications-context";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
import ConfirmRetypeModal from "@/components/confirm-retype-modal";
import { groupsDelete, groupsRename } from "@/lib/client/groups";
import ToggleButtonGroup from "@/components/toggle-button-group";
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
const router = useRouter();
const { groups, activeGroupId, setActiveGroup } = useGroupsContext();
const { notify } = useNotificationsContext();
const group = useMemo(() => groups.find(item => item.id === groupId) || null, [groups, groupId]);
const role = group?.role;
const isAdmin = role === "GROUP_ADMIN" || role === "GROUP_OWNER";
const isOwner = role === "GROUP_OWNER";
const canViewAudit = isAdmin;
const hasSyncedActiveRef = useRef(false);
const lastGroupIdRef = useRef(groupId);
useEffect(() => {
if (lastGroupIdRef.current !== groupId) {
lastGroupIdRef.current = groupId;
hasSyncedActiveRef.current = false;
}
if (!groupId || hasSyncedActiveRef.current) return;
if (activeGroupId !== groupId) setActiveGroup(groupId);
hasSyncedActiveRef.current = true;
}, [activeGroupId, groupId, setActiveGroup]);
const { tags, addTags, removeTag } = useTags(activeGroupId === groupId ? groupId : null);
const { settings, updateSettings } = useGroupSettings(activeGroupId === groupId ? groupId : null);
const { members, requests, currentUserId, approve, deny, promote, demote, kick, transferOwner, leave } = useGroupMembers(activeGroupId === groupId ? groupId : null);
const { links, create: createInvite, revoke: revokeInvite, revive: reviveInvite, remove: deleteInvite } = useGroupInvites(activeGroupId === groupId ? groupId : null);
const { events } = useGroupAudit(activeGroupId === groupId && canViewAudit ? groupId : null);
const [pendingTags, setPendingTags] = useState<string[]>([]);
const [toggleRemoveTags, setToggleRemoveTags] = useState<string[]>([]);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [renameValue, setRenameValue] = useState(group?.name || "");
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [confirmRenameOpen, setConfirmRenameOpen] = useState(false);
const [confirmLeaveOpen, setConfirmLeaveOpen] = useState(false);
const [confirmKick, setConfirmKick] = useState<{ userId: number; name: string } | null>(null);
const [confirmTransfer, setConfirmTransfer] = useState<{ userId: number; name: string } | null>(null);
const [confirmDeleteGroupOpen, setConfirmDeleteGroupOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [tagModalOpen, setTagModalOpen] = useState(false);
const [showAllTags, setShowAllTags] = useState(false);
const [inviteTtlDays, setInviteTtlDays] = useState(7);
const [inviteMaxUses, setInviteMaxUses] = useState<"UNLIMITED" | "SINGLE">("UNLIMITED");
const [auditOpen, setAuditOpen] = useState(false);
const [confirmDeleteInvite, setConfirmDeleteInvite] = useState<{ id: number; token: string } | null>(null);
const [auditFilters, setAuditFilters] = useState(["members", "tags", "settings", "entries"] as Array<"members" | "tags" | "settings" | "entries">);
const [auditQuery, setAuditQuery] = useState("");
const [auditFrom, setAuditFrom] = useState("");
const [auditTo, setAuditTo] = useState("");
const [auditLimit, setAuditLimit] = useState(10);
const [localAllowMemberTagManage, setLocalAllowMemberTagManage] = useState(settings.allowMemberTagManage);
const [localJoinPolicy, setLocalJoinPolicy] = useState(settings.joinPolicy);
const canManageTags = role === "GROUP_ADMIN" || role === "GROUP_OWNER" || localAllowMemberTagManage;
const adminCount = members.filter(member => member.role === "GROUP_ADMIN" || member.role === "GROUP_OWNER").length;
const isLastAdmin = isAdmin && adminCount <= 1;
useEffect(() => {
setRenameValue(group?.name || "");
}, [group?.name]);
useEffect(() => {
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
setLocalJoinPolicy(settings.joinPolicy);
}, [settings.allowMemberTagManage, settings.joinPolicy]);
async function handleSaveTags() {
if (!pendingTags.length) return;
const ok = await addTags(pendingTags);
if (ok) {
notify({ title: "Tags updated", message: pendingTags.join(", "), tone: "success" });
setPendingTags([]);
}
}
async function handleRemoveTag(tag: string) {
const ok = await removeTag(tag);
if (ok) notify({ title: "Tag removed", message: `#${tag}`, tone: "danger" });
}
async function handleConfirmDelete() {
if (!toggleRemoveTags.length) return;
await Promise.all(toggleRemoveTags.map(tag => removeTag(tag)));
notify({ title: "Tags removed", message: toggleRemoveTags.join(", "), tone: "danger" });
setToggleRemoveTags([]);
}
async function handleToggleAllowMembers(value: boolean) {
setLocalAllowMemberTagManage(value);
const ok = await updateSettings(value, localJoinPolicy);
if (ok) {
notify({ title: "Tag permissions updated" });
return;
}
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
notify({ title: "Update failed", message: "Could not save tag permissions.", tone: "danger" });
}
async function handleJoinPolicyChange(nextPolicy: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") {
setLocalJoinPolicy(nextPolicy);
const ok = await updateSettings(localAllowMemberTagManage, nextPolicy);
if (ok) {
notify({ title: "Join policy updated" });
return;
}
setLocalJoinPolicy(settings.joinPolicy);
notify({ title: "Update failed", message: "Could not save join policy.", tone: "danger" });
}
function handleOpenRenameModal() {
setRenameValue(group?.name || "");
setRenameModalOpen(true);
}
function handleCloseRenameModal() {
setRenameModalOpen(false);
setRenameValue(group?.name || "");
}
async function handleRenameGroup() {
const name = renameValue.trim();
if (!name) return;
const result = await groupsRename({ name });
if ("error" in result) {
notify({ title: "Rename failed", message: result.error.message, tone: "danger" });
return;
}
notify({ title: "Group renamed", message: name });
setConfirmRenameOpen(false);
setRenameModalOpen(false);
}
async function handleRoleChange(targetUserId: number, nextRole: "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER") {
if (!isAdmin) return;
const member = members.find(item => item.userId === targetUserId);
if (!member) return;
if (member.role === nextRole) return;
if (nextRole === "GROUP_OWNER") {
if (!isOwner) return;
await transferOwner(targetUserId);
return;
}
if (nextRole === "GROUP_ADMIN") {
await promote(targetUserId);
return;
}
await demote(targetUserId);
}
function getInviteLinkUrl(token: string) {
if (typeof window === "undefined") return token;
return `${window.location.origin}/invite/${token}`;
}
async function handleCopyInvite(token: string) {
try {
await navigator.clipboard.writeText(getInviteLinkUrl(token));
notify({ title: "Invite link copied", message: "Link copied to clipboard" });
} catch {
notify({ title: "Copy failed", tone: "danger" });
}
}
function formatInviteExpiry(expiresAt: string) {
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return "Unknown";
const diffMs = expiry - Date.now();
const absMs = Math.abs(diffMs);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const pick = (value: number, unit: string) => `${value} ${unit}${value === 1 ? "" : "s"}`;
let label = "";
if (absMs < hour) {
const value = Math.max(1, Math.round(absMs / minute));
label = pick(value, "minute");
} else if (absMs < day) {
const value = Math.max(1, Math.round(absMs / hour));
label = pick(value, "hour");
} else {
const value = Math.max(1, Math.round(absMs / day));
label = pick(value, "day");
}
return diffMs >= 0 ? `in ${label}` : `${label} ago`;
}
function isInviteExpired(expiresAt: string) {
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return false;
return Date.now() > expiry;
}
function eventCategory(eventType: string) {
const upper = eventType.toUpperCase();
if (upper.includes("SPENDING") || upper.includes("ENTRY")) return "entries" as const;
if (upper.includes("TAG")) return "tags" as const;
if (upper.includes("SETTING") || upper.includes("RENAMED")) return "settings" as const;
return "members" as const;
}
const memberLookup = useMemo(() => {
return new Map(members.map(member => [member.userId, { name: member.displayName || member.email, email: member.email }]));
}, [members]);
function getMemberLabel(userId?: number | null) {
if (!userId) return "System";
const member = memberLookup.get(userId);
return member?.name || `User ${userId}`;
}
const filteredEvents = events.filter(event => {
const category = eventCategory(event.eventType);
if (!auditFilters.includes(category)) return false;
if (auditQuery) {
const query = auditQuery.toLowerCase();
const actor = event.actorUserId ? memberLookup.get(event.actorUserId) : null;
const haystack = `${event.eventType} ${event.actorUserId ?? ""} ${actor?.name || ""} ${actor?.email || ""} ${event.requestId}`.toLowerCase();
if (!haystack.includes(query)) return false;
}
if (auditFrom) {
const from = new Date(auditFrom).getTime();
if (!Number.isNaN(from) && new Date(event.createdAt).getTime() < from) return false;
}
if (auditTo) {
const to = new Date(auditTo).getTime();
if (!Number.isNaN(to) && new Date(event.createdAt).getTime() > to + 24 * 60 * 60 * 1000 - 1) return false;
}
return true;
});
const todayKey = new Date().toISOString().slice(0, 10);
const logsToday = events.filter(event => event.createdAt?.slice(0, 10) === todayKey).length;
const mostActiveUser = (() => {
const counts = new Map<number, number>();
for (const event of events) {
if (!event.actorUserId) continue;
counts.set(event.actorUserId, (counts.get(event.actorUserId) ?? 0) + 1);
}
let top: number | null = null;
let topCount = 0;
counts.forEach((count, userId) => {
if (count > topCount) {
top = userId;
topCount = count;
}
});
if (!top) return null;
const member = memberLookup.get(top);
const name = member?.name || `User ${top}`;
const searchValue = member?.name || member?.email || String(top);
return { userId: top, count: topCount, name, searchValue };
})();
const mostActiveCount = mostActiveUser?.count ?? 0;
const renameDirty = Boolean(group && renameValue.trim() !== group.name);
const memberCount = members.length;
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
useEffect(() => {
if (!renameModalOpen && !tagModalOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (tagModalOpen) setTagModalOpen(false);
if (renameModalOpen) handleCloseRenameModal();
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]);
async function handleDeleteGroup() {
const result = await groupsDelete();
if ("error" in result) {
notify({ title: "Delete failed", message: result.error.message, tone: "danger" });
return;
}
setConfirmDeleteGroupOpen(false);
setDeleteConfirmText("");
notify({ title: "Group deleted", tone: "danger" });
router.push("/");
}
if (!group) {
return (
<div className="panel panel-accent p-4">
<div className="text-sm text-muted">Loading group settings...</div>
</div>
);
}
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
const hasMoreTags = tags.length > 5;
const tagsScrollable = showAllTags && tags.length > 15;
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">{group.name} settings</h1>
<p className="text-sm text-muted">Manage tags and permissions for this group.</p>
</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-3 py-1.5 text-sm"
onClick={() => router.push("/")}
>
Back
</button>
</div>
<div className="panel panel-accent p-4 space-y-4">
<div className="card-header">
<div className="card-title">General Settings</div>
</div>
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs text-soft">Group name</div>
<div className="text-base font-semibold">{group.name}</div>
</div>
{isAdmin ? (
<button
type="button"
className="rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={handleOpenRenameModal}
>
Change
</button>
) : null}
</div>
{!isAdmin ? (
<div className="text-xs text-soft">Only admins can rename the group.</div>
) : null}
<div className="divider" />
{isAdmin ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold">Allow members to manage tags</div>
<button
type="button"
aria-pressed={localAllowMemberTagManage}
className={`relative h-7 w-12 rounded-full border transition ${localAllowMemberTagManage ? "border-emerald-400 bg-emerald-500/20" : "border-red-400/60 bg-red-500/10"}`}
onClick={() => handleToggleAllowMembers(!localAllowMemberTagManage)}
>
<span
className={`absolute left-0.5 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full transition ${localAllowMemberTagManage ? "translate-x-[22px] bg-emerald-200" : "translate-x-0 bg-red-200"}`}
/>
</button>
</div>
<div className="divider" />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold">Group Tags ({tags.length})</div>
<div className="flex items-center gap-2">
{showAllTags ? (
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => setShowAllTags(false)}
>
Show less
</button>
) : null}
{canManageTags ? (
<button
type="button"
className="rounded-lg btn-accent px-2 py-1 text-xs font-semibold"
onClick={() => setTagModalOpen(true)}
>
Edit tags
</button>
) : null}
</div>
</div>
<div className={tagsScrollable ? "max-h-60 overflow-auto resize-y pr-1" : ""}>
<div className="flex flex-wrap gap-2">
{visibleTags.map(tag => (
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs text-[color:var(--color-text)]">
#{tag}
</span>
))}
{!showAllTags && hasMoreTags ? (
<button
type="button"
className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft"
onClick={() => setShowAllTags(true)}
aria-label="Show all tags"
>
more . . .
</button>
) : null}
{!tags.length ? <div className="text-xs text-soft">No tags yet.</div> : null}
</div>
</div>
</div>
</div>
{isAdmin ? (
<div className="panel panel-accent p-4 space-y-4">
<div className="card-header">
<div className="card-title">Join and Invites</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold">Join policy</div>
<ToggleButtonGroup
value={localJoinPolicy}
onChange={policy => handleJoinPolicyChange(policy)}
ariaLabel="Join policy"
className="flex flex-wrap gap-2"
buttonBaseClassName="rounded-lg border"
sizeClassName="px-3 py-1.5 text-xs font-semibold transition"
activeClassName="border-accent bg-accent-soft"
inactiveClassName="border-accent-weak bg-panel hover:border-accent"
options={[
{ value: "NOT_ACCEPTING", label: "Disabled" },
{ value: "AUTO_ACCEPT", label: "Auto" },
{ value: "APPROVAL_REQUIRED", label: "Manual" }
]}
/>
</div>
</div>
<div className="divider" />
<div className="space-y-2">
<div className="text-sm font-semibold">Join Requests ({requests.length})</div>
{!requests.length ? (
<div className="text-xs text-soft">No pending requests.</div>
) : (
<div className="space-y-2">
{requests.map(request => (
<div key={request.id} className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm">
<div>
<div className="font-medium">{request.displayName || request.email}</div>
<div className="text-xs text-soft">{request.email}</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg btn-outline-accent px-3 py-1.5 text-xs"
onClick={() => approve(request.userId, request.id)}
>
Approve
</button>
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-1.5 text-xs text-red-200"
onClick={() => deny(request.userId)}
>
Deny
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="divider" />
<div className="space-y-3">
<div className="text-sm font-semibold">Invite links</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="input-base px-2 py-1.5 text-xs"
value={inviteTtlDays}
onChange={e => setInviteTtlDays(Number(e.target.value))}
>
{[1, 2, 3, 4, 5, 6, 7].map(days => (
<option key={days} value={days}>{days} day{days === 1 ? "" : "s"}</option>
))}
</select>
<select
className="input-base px-2 py-1.5 text-xs"
value={inviteMaxUses}
onChange={e => setInviteMaxUses(e.target.value as "UNLIMITED" | "SINGLE")}
>
<option value="UNLIMITED">Unlimited</option>
<option value="SINGLE">1 use</option>
</select>
<button
type="button"
className="rounded-lg btn-accent px-3 py-1.5 text-xs font-semibold"
onClick={() => createInvite({ policy: localJoinPolicy, singleUse: inviteMaxUses === "SINGLE", ttlDays: inviteTtlDays })}
disabled={localJoinPolicy === "NOT_ACCEPTING"}
>
Create link
</button>
</div>
{!links.length ? (
<div className="text-xs text-soft">No invite links yet.</div>
) : (
<div className="space-y-2">
{links.map(link => (
<div
key={link.id}
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}</div>
{(() => {
const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt);
const options = [
{
value: "COPY",
label: "Copy link",
className: "btn-outline-accent",
disabled: localJoinPolicy === "NOT_ACCEPTING",
onClick: () => handleCopyInvite(link.token)
},
...(showRevive
? [{
value: "REVIVE",
label: "Revive",
className: "btn-outline-accent",
disabled: localJoinPolicy === "NOT_ACCEPTING",
onClick: () => reviveInvite(link.id, inviteTtlDays)
}]
: showRevoke
? [{
value: "REVOKE",
label: "Revoke",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => revokeInvite(link.id)
}]
: []),
{
value: "DELETE",
label: "Delete",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token })
}
];
return (
<ToggleButtonGroup
value={null}
ariaLabel="Invite actions"
className="flex items-center gap-2"
buttonBaseClassName="rounded-lg"
sizeClassName="px-2 py-1 text-xs"
activeClassName=""
inactiveClassName=""
options={options}
/>
);
})()}
</div>
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
<span>Expires {formatInviteExpiry(link.expiresAt)}</span>
<span>Uses: {link.singleUse ? "1 use" : "Unlimited"}</span>
<span>Status: {link.revokedAt ? "Revoked" : link.singleUse && link.usedAt ? "Used" : isInviteExpired(link.expiresAt) ? "Expired" : "Active"}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
) : null}
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Members</div>
<div className="text-xs text-soft">{memberCount} total</div>
</div>
<div className="space-y-2">
{members.map(member => {
const isSelf = member.userId === currentUserId;
const privilegeLabel = member.role === "GROUP_OWNER"
? "Owner - Full control"
: member.role === "GROUP_ADMIN"
? "Admin - Manage members"
: "Member - Entries only";
return (
<div
key={member.userId}
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border border-accent-weak px-3 py-2 text-sm ${isSelf ? "bg-accent-soft" : "bg-panel"}`}
>
<div>
<div className={`font-medium ${isSelf ? "text-[color:var(--color-text)]" : ""}`}>
{member.displayName || member.email}
{isSelf ? <span className="ml-2 rounded-full border border-accent-weak px-2 py-0.5 text-[10px] text-soft">You</span> : null}
</div>
<div className="text-xs text-soft">{member.email}</div>
<div className="mt-1 text-[11px] text-soft">{privilegeLabel}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{isAdmin ? (
<select
className="input-base px-2 py-1 text-xs"
value={member.role}
onChange={e => handleRoleChange(member.userId, e.target.value as "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER")}
disabled={member.role === "GROUP_OWNER"}
>
<option value="MEMBER">Member</option>
<option value="GROUP_ADMIN">Admin</option>
{isOwner ? <option value="GROUP_OWNER">Owner</option> : null}
</select>
) : (
<span className="text-xs text-soft">{member.role}</span>
)}
{isAdmin && member.role !== "GROUP_OWNER" ? (
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
onClick={() => setConfirmKick({ userId: member.userId, name: member.displayName || member.email })}
>
Remove
</button>
) : null}
{isOwner && member.role !== "GROUP_OWNER" ? (
<button
type="button"
className="rounded-lg btn-accent px-2 py-1 text-xs"
onClick={() => setConfirmTransfer({ userId: member.userId, name: member.displayName || member.email })}
>
Make owner
</button>
) : null}
</div>
</div>
);
})}
</div>
</div>
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title text-red-200">Danger zone</div>
</div>
<div className="space-y-2">
{showLeaveGroup ? (
<button
type="button"
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => setConfirmLeaveOpen(true)}
>
Leave group
</button>
) : null}
<button
type="button"
className="w-full rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
onClick={() => setConfirmDeleteGroupOpen(true)}
disabled={!isOwner}
>
Delete group
</button>
</div>
{!isOwner ? <div className="text-xs text-soft">Only the owner can delete this group.</div> : null}
</div>
{canViewAudit ? (
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Audit log</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => setAuditOpen(prev => !prev)}
>
{auditOpen ? "Collapse" : "Expand"}
</button>
</div>
{auditOpen ? (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2">
<div className="text-[11px] text-soft">Total logs</div>
<div className="text-base font-semibold">{events.length}</div>
</div>
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2">
<div className="text-[11px] text-soft">Logs today</div>
<div className="text-base font-semibold">{logsToday}</div>
</div>
<button
type="button"
className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-left disabled:opacity-60"
onClick={() => mostActiveUser ? setAuditQuery(mostActiveUser.searchValue) : null}
disabled={!mostActiveUser}
>
<div className="text-[11px] text-soft">Most Active User ({mostActiveCount})</div>
<div className="text-base font-semibold">
{mostActiveUser ? `${mostActiveUser.name}` : "-"}
</div>
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
{([
{ key: "entries", label: "Entries" },
{ key: "members", label: "Members" },
{ key: "tags", label: "Tags" },
{ key: "settings", label: "Settings" }
] as const).map(filter => (
<button
key={filter.key}
type="button"
className={`rounded-lg border px-2 py-1 text-xs font-semibold ${auditFilters.includes(filter.key) ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => setAuditFilters(prev => prev.includes(filter.key) ? prev.filter(item => item !== filter.key) : [...prev, filter.key])}
>
{filter.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
className="input-base w-full px-9 py-2 text-sm"
placeholder="Search by user, action, or request id"
value={auditQuery}
onChange={e => setAuditQuery(e.target.value)}
/>
</div>
<div className="flex min-w-[220px] flex-1 items-center gap-2 flex-nowrap">
<div className="relative flex-1">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<input
type="date"
className="no-date-icon input-base date-input w-full pl-9 pr-3 py-2 text-sm"
value={auditFrom}
onChange={e => setAuditFrom(e.target.value)}
/>
</div>
<div className="relative flex-1">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<input
type="date"
className="no-date-icon input-base date-input w-full pl-9 pr-3 py-2 text-sm"
value={auditTo}
onChange={e => setAuditTo(e.target.value)}
/>
</div>
</div>
</div>
{!filteredEvents.length ? (
<div className="text-xs text-soft">No audit events match your filters.</div>
) : (
<div className="max-h-72 space-y-2 overflow-auto rounded-lg border border-accent-weak bg-panel p-2">
{filteredEvents.slice(0, auditLimit).map(event => (
<div key={event.id} className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
<div className="text-sm font-semibold text-[color:var(--color-text)]">{event.eventType}</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-soft">
<span>Actor: {getMemberLabel(event.actorUserId)}</span>
<span>Role: {event.actorRole ?? "-"}</span>
<span>{new Date(event.createdAt).toLocaleString()}</span>
</div>
</div>
))}
{filteredEvents.length > auditLimit ? (
<button
type="button"
className="w-full rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => setAuditLimit(prev => prev + 10)}
>
Load more
</button>
) : null}
</div>
)}
</>
) : (
<div className="text-xs text-soft">Audit log is collapsed.</div>
)}
</div>
) : null}
{renameModalOpen ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={handleCloseRenameModal}>
<div
className="relative w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key === "Escape") handleCloseRenameModal();
if (event.key === "Enter" && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
}}
role="dialog"
tabIndex={-1}
>
<button
type="button"
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
onClick={handleCloseRenameModal}
aria-label="Close"
>
x
</button>
<div className="text-lg font-semibold">Change group name</div>
<input
className={`mt-4 w-full input-base px-3 py-2 text-sm ${renameValue.trim() ? "" : "border-red-400/70"}`}
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
placeholder="Group name"
/>
{renameDirty ? (
<div className="mt-3 rounded-lg border border-yellow-400/60 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
You have unsaved changes.
</div>
) : null}
<div className="mt-4 flex items-center gap-2">
{renameDirty ? (
<>
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={handleCloseRenameModal}
>
Discard changes
</button>
<button
type="button"
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={() => setConfirmRenameOpen(true)}
disabled={!isAdmin || !renameValue.trim()}
>
Rename
</button>
</>
) : (
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={handleCloseRenameModal}
>
Dismiss
</button>
)}
</div>
</div>
</div>
) : null}
{tagModalOpen ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => setTagModalOpen(false)}>
<div
className="w-full max-w-xl rounded-2xl border border-accent-weak bg-panel p-5"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key === "Escape") setTagModalOpen(false);
// if (event.key === "Enter" && !event.shiftKey && pendingTags.length && canManageTags) handleSaveTags();
}}
role="dialog"
tabIndex={-1}
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Edit tags</div>
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => setTagModalOpen(false)} aria-label="Close">x</button>
</div>
<div className="mt-4 space-y-3">
<TagInput
label="Add tags"
tags={pendingTags}
suggestions={tags}
enableBackspaceRemove
onToggleTag={tag => setPendingTags(prev => prev.filter(item => item !== tag))}
onAddTag={tag => setPendingTags(prev => prev.includes(tag) ? prev : [...prev, tag])}
/>
<button
type="button"
className="rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={handleSaveTags}
disabled={!pendingTags.length || !canManageTags}
>
Save tags
</button>
{!canManageTags ? (
<div className="text-xs text-soft">Only admins can add new tags.</div>
) : null}
<div className="divider" />
<div className="text-sm font-semibold">Existing tags</div>
<div className="max-h-60 overflow-auto resize-y pr-1">
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<button
key={tag}
type="button"
className={`rounded-full border px-2 py-0.5 text-xs text-[color:var(--color-text)] ${toggleRemoveTags.includes(tag) ? "border-red-400/60 text-red-200 bg-red-500/10" : "border-accent-weak bg-accent-soft hover:border-accent"}`}
onClick={() => {
if (!canManageTags) return;
setToggleRemoveTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]);
}}
>
#{tag}
</button>
))}
{!tags.length ? <div className="text-xs text-soft">No tags yet.</div> : null}
</div>
</div>
{toggleRemoveTags.length ? (
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
onClick={() => setConfirmDeleteOpen(true)}
>
Delete selected ({toggleRemoveTags.length})
</button>
) : null}
</div>
</div>
</div>
) : null}
<ConfirmSlideModal
isOpen={confirmDeleteOpen}
title="Delete selected tags"
description="Tags will be removed from this group and any entries using them."
confirmLabel="Delete tags"
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => {
setConfirmDeleteOpen(false);
handleConfirmDelete();
}}
/>
<ConfirmSlideModal
isOpen={Boolean(confirmDeleteInvite)}
title="Delete invite link"
description={confirmDeleteInvite ? `Delete invite ${confirmDeleteInvite.token.slice(0, 6)}...${confirmDeleteInvite.token.slice(-4)}?` : ""}
confirmLabel="Delete link"
onClose={() => setConfirmDeleteInvite(null)}
onConfirm={async () => {
if (!confirmDeleteInvite) return;
const target = confirmDeleteInvite;
setConfirmDeleteInvite(null);
const ok = await deleteInvite(target.id);
if (ok) notify({ title: "Invite deleted", tone: "danger" });
}}
/>
<ConfirmSlideModal
isOpen={confirmRenameOpen}
title="Rename group"
description={`Change group name to "${renameValue.trim()}"?`}
confirmLabel="Rename"
onClose={() => setConfirmRenameOpen(false)}
onConfirm={handleRenameGroup}
/>
<ConfirmSlideModal
isOpen={confirmLeaveOpen}
title="Leave group"
description="You will lose access to this group."
confirmLabel="Leave"
onClose={() => setConfirmLeaveOpen(false)}
onConfirm={async () => {
setConfirmLeaveOpen(false);
const ok = await leave();
if (ok) {
notify({ title: "Left group" });
router.push("/");
}
}}
/>
<ConfirmSlideModal
isOpen={Boolean(confirmKick)}
title="Kick member"
description={confirmKick ? `Remove ${confirmKick.name} from this group?` : ""}
confirmLabel="Kick"
onClose={() => setConfirmKick(null)}
onConfirm={async () => {
if (!confirmKick) return;
const target = confirmKick;
setConfirmKick(null);
const ok = await kick(target.userId);
if (ok) notify({ title: "Member removed", message: target.name, tone: "danger" });
}}
/>
<ConfirmSlideModal
isOpen={Boolean(confirmTransfer)}
title="Transfer ownership"
description={confirmTransfer ? `Make ${confirmTransfer.name} the new owner?` : ""}
confirmLabel="Transfer"
onClose={() => setConfirmTransfer(null)}
onConfirm={async () => {
if (!confirmTransfer) return;
const target = confirmTransfer;
setConfirmTransfer(null);
const ok = await transferOwner(target.userId);
if (ok) notify({ title: "Ownership transferred", message: target.name });
}}
/>
<ConfirmRetypeModal
isOpen={confirmDeleteGroupOpen}
title="Delete group"
description="Type DELETE to confirm. This cannot be undone."
expectedText="DELETE"
value={deleteConfirmText}
onChange={setDeleteConfirmText}
confirmLabel="Delete"
onClose={() => setConfirmDeleteGroupOpen(false)}
onConfirm={handleDeleteGroup}
/>
</div>
</>
);
}