175 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|