193 lines
12 KiB
JavaScript
193 lines
12 KiB
JavaScript
import React, { useState } from 'react'
|
|
import { router, usePage } from '@inertiajs/react'
|
|
import StudioLayout from '../../Layouts/StudioLayout'
|
|
|
|
function overrideMap(member) {
|
|
const entries = Array.isArray(member.permission_overrides) ? member.permission_overrides : []
|
|
|
|
return entries.reduce((carry, item) => {
|
|
if (!item?.key) return carry
|
|
carry[item.key] = item.is_allowed === true ? 'allow' : 'deny'
|
|
return carry
|
|
}, {})
|
|
}
|
|
|
|
function prettifyPermission(value) {
|
|
return String(value || '').replaceAll('_', ' ')
|
|
}
|
|
|
|
export default function StudioGroupMembers() {
|
|
const { props } = usePage()
|
|
const canManageMembers = Boolean(props.canManageMembers)
|
|
const [invite, setInvite] = useState({ username: '', role: 'contributor', note: '' })
|
|
const [search, setSearch] = useState('')
|
|
const [editingMemberId, setEditingMemberId] = useState(null)
|
|
const [permissionDrafts, setPermissionDrafts] = useState({})
|
|
const members = Array.isArray(props.members) ? props.members : []
|
|
const permissionOptions = Array.isArray(props.permissionOverrideOptions) ? props.permissionOverrideOptions : []
|
|
|
|
const filteredMembers = members.filter((member) => {
|
|
const haystack = `${member.user?.name || ''} ${member.user?.username || ''} ${member.role_label || member.role || ''}`.toLowerCase()
|
|
|
|
return haystack.includes(search.trim().toLowerCase())
|
|
})
|
|
|
|
const confirmTransfer = (member) => {
|
|
if (!window.confirm(`Transfer ownership of this group to ${member.user?.name || member.user?.username}? This removes your owner privileges immediately.`)) {
|
|
return
|
|
}
|
|
|
|
router.post(props.endpoints?.transferPattern.replace('__MEMBER__', String(member.id)))
|
|
}
|
|
|
|
const confirmRemoval = (member) => {
|
|
if (!window.confirm(`Remove ${member.user?.name || member.user?.username} from this group?`)) {
|
|
return
|
|
}
|
|
|
|
router.delete(props.endpoints?.deletePattern.replace('__MEMBER__', String(member.id)))
|
|
}
|
|
|
|
const openPermissionEditor = (member) => {
|
|
setEditingMemberId(member.id)
|
|
setPermissionDrafts((current) => ({ ...current, [member.id]: overrideMap(member) }))
|
|
}
|
|
|
|
const setPermissionState = (memberId, key, value) => {
|
|
setPermissionDrafts((current) => ({
|
|
...current,
|
|
[memberId]: {
|
|
...(current[memberId] || {}),
|
|
[key]: value,
|
|
},
|
|
}))
|
|
}
|
|
|
|
const savePermissions = (member) => {
|
|
const state = permissionDrafts[member.id] || {}
|
|
const payload = permissionOptions
|
|
.filter((option) => state[option.value] === 'allow' || state[option.value] === 'deny')
|
|
.map((option) => ({ key: option.value, is_allowed: state[option.value] === 'allow' }))
|
|
|
|
router.patch(props.endpoints?.permissionsPattern.replace('__MEMBER__', String(member.id)), {
|
|
permission_overrides: payload,
|
|
}, {
|
|
onSuccess: () => setEditingMemberId(null),
|
|
})
|
|
}
|
|
|
|
return (
|
|
<StudioLayout title={props.title} subtitle={props.description}>
|
|
{canManageMembers ? (
|
|
<section className="mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<h2 className="text-lg font-semibold text-white">Invite member</h2>
|
|
{props.endpoints?.invitations ? <a href={props.endpoints.invitations} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage invitations</a> : null}
|
|
</div>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_0.8fr_1fr_auto]">
|
|
<input value={invite.username} onChange={(event) => setInvite((current) => ({ ...current, username: event.target.value }))} placeholder="Username" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<select value={invite.role} onChange={(event) => setInvite((current) => ({ ...current, role: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
|
<option value="contributor">Contributor</option>
|
|
<option value="editor">Editor</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
<input value={invite.note} onChange={(event) => setInvite((current) => ({ ...current, note: event.target.value }))} placeholder="Optional note" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<button type="button" onClick={() => router.post(props.endpoints?.invite, invite)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100">Invite</button>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-white">Member directory</h2>
|
|
<p className="mt-1 text-sm text-slate-400">Search the current roster, then adjust roles or membership status.</p>
|
|
</div>
|
|
<label className="grid gap-2 text-sm text-slate-300 md:min-w-[260px]">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search members</span>
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Name, username, or role" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-5 overflow-hidden rounded-[24px] border border-white/10">
|
|
<div className="hidden grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] gap-3 border-b border-white/10 bg-white/[0.04] px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 md:grid">
|
|
<span>Member</span>
|
|
<span>Role</span>
|
|
<span>Status</span>
|
|
<span>Actions</span>
|
|
</div>
|
|
<div className="divide-y divide-white/10">
|
|
{filteredMembers.map((member) => (
|
|
<article key={member.id} className="grid gap-4 px-4 py-4 md:grid-cols-[minmax(0,1.5fr)_160px_120px_minmax(0,220px)] md:items-center">
|
|
<div className="flex items-center gap-3">
|
|
{member.user?.avatar_url ? <img src={member.user.avatar_url} alt={member.user.name || member.user.username} className="h-12 w-12 rounded-2xl object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
|
|
<div>
|
|
<div className="font-semibold text-white">{member.user?.name || member.user?.username}</div>
|
|
<div className="text-sm text-slate-400">@{member.user?.username || 'member'}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
{canManageMembers && member.role !== 'owner' ? (
|
|
<select value={member.role} onChange={(event) => router.patch(props.endpoints?.updatePattern.replace('__MEMBER__', String(member.id)), { role: event.target.value })} className="w-full rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-white outline-none">
|
|
<option value="contributor">Contributor</option>
|
|
<option value="editor">Editor</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
) : <span className="inline-flex rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100">{member.role === 'owner' ? 'Owner' : (member.role_label || member.role)}</span>}
|
|
{Array.isArray(member.permission_overrides) && member.permission_overrides.length > 0 ? (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{member.permission_overrides.map((permission) => (
|
|
<span key={`${permission.key}-${permission.is_allowed ? 'allow' : 'deny'}`} className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] ${permission.is_allowed ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
|
|
{permission.is_allowed ? 'Allow' : 'Deny'} {prettifyPermission(permission.key)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div>
|
|
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">{member.status}</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{member.can_manage_permissions ? <button type="button" onClick={() => openPermissionEditor(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Permissions</button> : null}
|
|
{canManageMembers && member.can_transfer ? <button type="button" onClick={() => confirmTransfer(member)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Transfer</button> : null}
|
|
{canManageMembers && member.can_revoke ? <button type="button" onClick={() => confirmRemoval(member)} className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100">Remove</button> : null}
|
|
</div>
|
|
{editingMemberId === member.id ? (
|
|
<div className="md:col-span-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white">Permission overrides</h3>
|
|
<p className="mt-1 text-xs text-slate-400">Set each advanced capability to inherit, allow, or deny.</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => setEditingMemberId(null)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white">Cancel</button>
|
|
<button type="button" onClick={() => savePermissions(member)} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-2 text-sm font-semibold text-sky-100">Save</button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
|
{permissionOptions.map((option) => {
|
|
const current = permissionDrafts[member.id]?.[option.value] || 'inherit'
|
|
|
|
return (
|
|
<div key={option.value} className="rounded-2xl border border-white/10 bg-white/[0.03] p-3">
|
|
<div className="text-sm font-semibold text-white">{option.label}</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'inherit')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'inherit' ? 'border-white/20 bg-white/[0.08] text-white' : 'border-white/10 bg-transparent text-slate-300'}`}>Inherit</button>
|
|
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'allow')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'allow' ? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Allow</button>
|
|
<button type="button" onClick={() => setPermissionState(member.id, option.value, 'deny')} className={`rounded-full border px-3 py-1.5 text-xs font-semibold ${current === 'deny' ? 'border-rose-300/20 bg-rose-400/10 text-rose-100' : 'border-white/10 bg-transparent text-slate-300'}`}>Deny</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
))}
|
|
{filteredMembers.length === 0 ? <div className="px-4 py-8 text-sm text-slate-400">No members match the current search.</div> : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</StudioLayout>
|
|
)
|
|
} |