Files
root-org/src/lib/components/settings/SettingsMembers.svelte

384 lines
9.7 KiB
Svelte

<script lang="ts">
import {
Button,
Modal,
Input,
Select,
Avatar,
} from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
interface ProfileData {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Member {
id: string;
user_id: string;
role: string;
role_id: string | null;
invited_at: string;
profiles: ProfileData | ProfileData[] | null;
}
interface OrgRole {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
}
interface Invite {
id: string;
email: string;
role: string;
role_id: string | null;
token: string;
expires_at: string;
created_at: string;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
userId: string;
members: Member[];
roles: OrgRole[];
invites: Invite[];
}
let {
supabase,
orgId,
userId,
members = $bindable(),
roles,
invites = $bindable(),
}: Props = $props();
let showInviteModal = $state(false);
let inviteEmail = $state("");
let inviteRole = $state("editor");
let isSendingInvite = $state(false);
let showMemberModal = $state(false);
let selectedMember = $state<Member | null>(null);
let selectedMemberRole = $state("");
async function sendInvite() {
if (!inviteEmail.trim()) return;
isSendingInvite = true;
const email = inviteEmail.toLowerCase().trim();
// Delete any existing invite for this email first (handles 409 conflict)
await supabase
.from("org_invites")
.delete()
.eq("org_id", orgId)
.eq("email", email);
const { data: invite, error } = await supabase
.from("org_invites")
.insert({
org_id: orgId,
email,
role: inviteRole,
invited_by: userId,
expires_at: new Date(
Date.now() + INVITE_EXPIRY_MS,
).toISOString(),
})
.select()
.single();
if (!error && invite) {
invites = invites.filter((i) => i.email !== email);
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
} else if (error) {
toasts.error(m.toast_error_invite({ error: error.message }));
}
isSendingInvite = false;
}
async function cancelInvite(inviteId: string) {
await supabase.from("org_invites").delete().eq("id", inviteId);
invites = invites.filter((i) => i.id !== inviteId);
}
function openMemberModal(member: Member) {
selectedMember = member;
selectedMemberRole = member.role;
showMemberModal = true;
}
async function updateMemberRole() {
if (!selectedMember) return;
const { error } = await supabase
.from("org_members")
.update({ role: selectedMemberRole })
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_update_role());
return;
}
members = members.map((m) =>
m.id === selectedMember!.id
? { ...m, role: selectedMemberRole }
: m,
);
showMemberModal = false;
}
async function removeMember() {
if (!selectedMember) return;
const rp = selectedMember.profiles;
const prof = Array.isArray(rp) ? rp[0] : rp;
if (
!confirm(
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
)
)
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_remove_member());
return;
}
members = members.filter((m) => m.id !== selectedMember!.id);
showMemberModal = false;
}
</script>
<div class="space-y-4 max-w-2xl">
<div class="flex items-center justify-between">
<div>
<h2 class="font-heading text-body text-white">
{m.settings_members_title({
count: String(members.length),
})}
</h2>
</div>
<Button size="sm" icon="person_add" onclick={() => (showInviteModal = true)}>
{m.settings_members_invite()}
</Button>
</div>
<!-- Pending Invites -->
{#if invites.length > 0}
<div class="bg-dark/30 border border-light/5 rounded-xl p-4">
<h3 class="text-body-sm font-heading text-light/60 mb-3">
{m.settings_members_pending()}
</h3>
<div class="space-y-2">
{#each invites as invite}
<div
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
>
<div>
<p class="text-body-sm text-white">{invite.email}</p>
<p class="text-[11px] text-light/40">
Invited as {invite.role} • Expires {new Date(
invite.expires_at,
).toLocaleDateString()}
</p>
</div>
<div class="flex items-center gap-1.5">
<button
type="button"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() =>
navigator.clipboard.writeText(
`${window.location.origin}/invite/${invite.token}`,
)}
title={m.settings_members_copy_link()}
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">content_copy</span>
</button>
<button
type="button"
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
onclick={() => cancelInvite(invite.id)}
title="Cancel invite"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">close</span>
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Members List -->
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<div class="divide-y divide-light/5">
{#each members as member}
{@const rawProfile = member.profiles}
{@const profile = Array.isArray(rawProfile)
? rawProfile[0]
: rawProfile}
<div
class="flex items-center justify-between px-4 py-3 hover:bg-light/5 transition-colors"
>
<div class="flex items-center gap-3">
<Avatar
name={profile?.full_name || profile?.email || "?"}
src={profile?.avatar_url}
size="sm"
/>
<div>
<p class="text-body-sm text-white">
{profile?.full_name ||
profile?.email ||
"Unknown User"}
</p>
<p class="text-[11px] text-light/40">
{profile?.email || "No email"}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 text-[10px] rounded-md capitalize font-body"
style="background-color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}20; color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}">{member.role}</span
>
{#if member.user_id !== userId && member.role !== "owner"}
<button
type="button"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => openMemberModal(member)}
title="Edit"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Invite Member Modal -->
<Modal
isOpen={showInviteModal}
onClose={() => (showInviteModal = false)}
title="Invite Member"
>
<div class="space-y-4">
<Input
type="email"
label="Email address"
bind:value={inviteEmail}
placeholder="colleague@example.com"
/>
<Select
label="Role"
bind:value={inviteRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer - Can view content" },
{
value: "commenter",
label: "Commenter - Can view and comment",
},
{
value: "editor",
label: "Editor - Can create and edit content",
},
{
value: "admin",
label: "Admin - Can manage members and settings",
},
]}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
>
<Button
onclick={sendInvite}
loading={isSendingInvite}
disabled={!inviteEmail.trim()}>Send Invite</Button
>
</div>
</div>
</Modal>
<!-- Edit Member Modal -->
<Modal
isOpen={showMemberModal}
onClose={() => (showMemberModal = false)}
title="Edit Member"
>
{#if selectedMember}
{@const rawP = selectedMember.profiles}
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(memberProfile?.full_name ||
memberProfile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{memberProfile?.full_name || "No name"}
</p>
<p class="text-sm text-light/50">
{memberProfile?.email || "No email"}
</p>
</div>
</div>
<Select
label="Role"
bind:value={selectedMemberRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer" },
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
]}
/>
<div class="flex items-center justify-between pt-2">
<Button variant="danger" onclick={removeMember}
>Remove from Org</Button
>
<div class="flex gap-2">
<Button
variant="tertiary"
onclick={() => (showMemberModal = false)}>Cancel</Button
>
<Button onclick={updateMemberRole}>Save</Button>
</div>
</div>
</div>
{/if}
</Modal>