Mega push vol 5, working on messaging now
This commit is contained in:
398
src/lib/components/settings/SettingsMembers.svelte
Normal file
398
src/lib/components/settings/SettingsMembers.svelte
Normal file
@@ -0,0 +1,398 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
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-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-light">
|
||||
{m.settings_members_title({
|
||||
count: String(members.length),
|
||||
})}
|
||||
</h2>
|
||||
<Button onclick={() => (showInviteModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="9"
|
||||
cy="7"
|
||||
r="4"
|
||||
/><line x1="19" y1="8" x2="19" y2="14" /><line
|
||||
x1="22"
|
||||
y1="11"
|
||||
x2="16"
|
||||
y2="11"
|
||||
/>
|
||||
</svg>
|
||||
{m.settings_members_invite()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
{#if invites.length > 0}
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-medium text-light/70 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-light">{invite.email}</p>
|
||||
<p class="text-xs text-light/40">
|
||||
Invited as {invite.role} • Expires {new Date(
|
||||
invite.expires_at,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/invite/${invite.token}`,
|
||||
)}
|
||||
>{m.settings_members_copy_link()}</Button
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => cancelInvite(invite.id)}
|
||||
>Cancel</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Members List -->
|
||||
<Card>
|
||||
<div class="divide-y divide-light/10">
|
||||
{#each members as member}
|
||||
{@const rawProfile = member.profiles}
|
||||
{@const profile = Array.isArray(rawProfile)
|
||||
? rawProfile[0]
|
||||
: rawProfile}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
{(profile?.full_name ||
|
||||
profile?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-light font-medium">
|
||||
{profile?.full_name ||
|
||||
profile?.email ||
|
||||
"Unknown User"}
|
||||
</p>
|
||||
<p class="text-sm text-light/50">
|
||||
{profile?.email || "No email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full capitalize"
|
||||
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
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() => openMemberModal(member)}
|
||||
>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</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>
|
||||
Reference in New Issue
Block a user