384 lines
9.7 KiB
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>
|