fiddy/apps/web/features/groups/components/group-settings-join-invites-card.tsx

175 lines
10 KiB
TypeScript

"use client";
import type { GroupSettingsViewModelState } from "@/features/groups/components/use-group-settings-view-model";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
type GroupSettingsJoinInvitesCardProps = {
vm: GroupSettingsViewModelState;
};
export default function GroupSettingsJoinInvitesCard({ vm }: GroupSettingsJoinInvitesCardProps) {
if (!vm.isAdmin) return null;
return (
<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={vm.localJoinPolicy}
onChange={vm.handleJoinPolicyChange}
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 ({vm.requests.length})</div>
{!vm.requests.length ? (
<div className="text-xs text-soft">No pending requests.</div>
) : (
<div className="space-y-2">
{vm.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={() => vm.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={() => vm.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={vm.inviteTtlDays}
onChange={event => vm.setInviteTtlDays(Number(event.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={vm.inviteMaxUses}
onChange={event => vm.setInviteMaxUses(event.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={() => vm.createInvite({ policy: vm.localJoinPolicy, singleUse: vm.inviteMaxUses === "SINGLE", ttlDays: vm.inviteTtlDays })}
disabled={vm.localJoinPolicy === "NOT_ACCEPTING"}
>
Create link
</button>
</div>
{!vm.links.length ? (
<div className="text-xs text-soft">No invite links yet.</div>
) : (
<div className="space-y-2">
{vm.links.map(link => (
<div
key={link.id}
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || vm.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 || vm.isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !vm.isInviteExpired(link.expiresAt);
const options = [
{
value: "COPY",
label: "Copy link",
className: "btn-outline-accent",
disabled: vm.localJoinPolicy === "NOT_ACCEPTING",
onClick: () => vm.handleCopyInvite(link.token)
},
...(showRevive
? [{
value: "REVIVE",
label: "Revive",
className: "btn-outline-accent",
disabled: vm.localJoinPolicy === "NOT_ACCEPTING",
onClick: () => vm.reviveInvite(link.id, vm.inviteTtlDays)
}]
: showRevoke
? [{
value: "REVOKE",
label: "Revoke",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => vm.revokeInvite(link.id)
}]
: []),
{
value: "DELETE",
label: "Delete",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => vm.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 {vm.formatInviteExpiry(link.expiresAt)}</span>
<span>Uses: {link.singleUse ? "1 use" : "Unlimited"}</span>
<span>Status: {link.revokedAt ? "Revoked" : link.singleUse && link.usedAt ? "Used" : vm.isInviteExpired(link.expiresAt) ? "Expired" : "Active"}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}