YEs
This commit is contained in:
@@ -15,4 +15,8 @@ MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||
# Used to auto-provision Matrix accounts for users
|
||||
MATRIX_ADMIN_TOKEN=
|
||||
|
||||
RESEND_API_KEY=
|
||||
# Resend email integration (resend.com)
|
||||
# Free tier: 100 emails/day. Verify a domain at resend.com/domains first.
|
||||
RESEND_API_KEY=
|
||||
# The verified sender email address (e.g. noreply@yourdomain.com)
|
||||
RESEND_FROM_EMAIL=
|
||||
@@ -987,5 +987,9 @@
|
||||
"onboarding_skip": "Skip for now",
|
||||
"invite_email_subject": "{orgName} — You're invited to join",
|
||||
"invite_email_sent": "Invite email sent to {email}",
|
||||
"toast_error_send_invite_email": "Failed to send invite email"
|
||||
}
|
||||
"toast_error_send_invite_email": "Failed to send invite email",
|
||||
"settings_transfer_ownership": "Transfer ownership",
|
||||
"settings_transfer_confirm": "Transfer ownership to {name}? You will be demoted to admin. This action is immediate.",
|
||||
"toast_error_transfer_ownership": "Failed to transfer ownership",
|
||||
"toast_success_transfer_ownership": "Ownership transferred to {name}"
|
||||
}
|
||||
|
||||
@@ -987,5 +987,9 @@
|
||||
"onboarding_skip": "Jäta vahele",
|
||||
"invite_email_subject": "{orgName} — Oled kutsutud liituma",
|
||||
"invite_email_sent": "Kutse e-kiri saadetud aadressile {email}",
|
||||
"toast_error_send_invite_email": "Kutse e-kirja saatmine ebaõnnestus"
|
||||
"toast_error_send_invite_email": "Kutse e-kirja saatmine ebaõnnestus",
|
||||
"settings_transfer_ownership": "Kanna omanikõigused üle",
|
||||
"settings_transfer_confirm": "Kanna omanikõigused üle kasutajale {name}? Sind alandatakse adminiks. See toiming on kohene.",
|
||||
"toast_error_transfer_ownership": "Omanikõiguste ülekandmine ebaõnnestus",
|
||||
"toast_success_transfer_ownership": "Omanikõigused kanti üle kasutajale {name}"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
let showMemberModal = $state(false);
|
||||
let selectedMember = $state<Member | null>(null);
|
||||
let selectedMemberRole = $state("");
|
||||
let isTransferring = $state(false);
|
||||
|
||||
const currentUserRole = $derived(
|
||||
members.find((m) => m.user_id === userId)?.role ?? "viewer",
|
||||
);
|
||||
|
||||
async function sendInvite() {
|
||||
if (!inviteEmail.trim()) return;
|
||||
@@ -181,6 +186,65 @@
|
||||
members = members.filter((m) => m.id !== selectedMember!.id);
|
||||
showMemberModal = false;
|
||||
}
|
||||
|
||||
async function transferOwnership() {
|
||||
if (!selectedMember) return;
|
||||
const rp = selectedMember.profiles;
|
||||
const prof = Array.isArray(rp) ? rp[0] : rp;
|
||||
const targetName = prof?.full_name || prof?.email || "this member";
|
||||
|
||||
if (!confirm(m.settings_transfer_confirm({ name: targetName }))) return;
|
||||
|
||||
isTransferring = true;
|
||||
|
||||
// Demote current owner to admin
|
||||
const currentOwner = members.find((m) => m.user_id === userId);
|
||||
if (!currentOwner) {
|
||||
isTransferring = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: demoteError } = await supabase
|
||||
.from("org_members")
|
||||
.update({ role: "admin" })
|
||||
.eq("id", currentOwner.id);
|
||||
|
||||
if (demoteError) {
|
||||
toasts.error(m.toast_error_transfer_ownership());
|
||||
isTransferring = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Promote target to owner
|
||||
const { error: promoteError } = await supabase
|
||||
.from("org_members")
|
||||
.update({ role: "owner" })
|
||||
.eq("id", selectedMember.id);
|
||||
|
||||
if (promoteError) {
|
||||
// Rollback: re-promote current user
|
||||
await supabase
|
||||
.from("org_members")
|
||||
.update({ role: "owner" })
|
||||
.eq("id", currentOwner.id);
|
||||
toasts.error(m.toast_error_transfer_ownership());
|
||||
isTransferring = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
members = members.map((mb) => {
|
||||
if (mb.id === currentOwner.id) return { ...mb, role: "admin" };
|
||||
if (mb.id === selectedMember!.id) return { ...mb, role: "owner" };
|
||||
return mb;
|
||||
});
|
||||
|
||||
isTransferring = false;
|
||||
showMemberModal = false;
|
||||
toasts.success(
|
||||
m.toast_success_transfer_ownership({ name: targetName }),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 max-w-2xl">
|
||||
@@ -411,13 +475,27 @@
|
||||
{ value: "admin", label: m.role_admin() },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-error hover:underline self-start"
|
||||
onclick={removeMember}
|
||||
>
|
||||
{m.settings_members_remove()}
|
||||
</button>
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-error hover:underline"
|
||||
onclick={removeMember}
|
||||
>
|
||||
{m.settings_members_remove()}
|
||||
</button>
|
||||
{#if currentUserRole === "owner"}
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-warning hover:underline"
|
||||
onclick={transferOwnership}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
{isTransferring
|
||||
? "..."
|
||||
: m.settings_transfer_ownership()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: `${orgName} <onboarding@resend.dev>`,
|
||||
from: `${orgName} <${env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'}>`,
|
||||
to: [email],
|
||||
subject: `${orgName} — You're invited to join`,
|
||||
html: buildInviteEmailHtml(orgName, role, inviteUrl),
|
||||
|
||||
@@ -34,6 +34,13 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
const org = (invite as Record<string, unknown>).organizations as { id: string; name: string; slug: string } | null;
|
||||
|
||||
if (!org) {
|
||||
return {
|
||||
error: 'Invalid or expired invite link',
|
||||
token
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
invite: {
|
||||
id: invite.id,
|
||||
|
||||
@@ -135,11 +135,11 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title
|
||||
>{data.invite?.org.name
|
||||
? `${m.invite_title()} | ${data.invite.org.name}`
|
||||
: m.invite_title()}</title
|
||||
>
|
||||
{#if data.invite?.org}
|
||||
<title>{m.invite_title()} | {data.invite.org.name}</title>
|
||||
{:else}
|
||||
<title>{m.invite_title()}</title>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
|
||||
12
supabase/migrations/057_invite_org_rls.sql
Normal file
12
supabase/migrations/057_invite_org_rls.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Allow anyone to read organization basic info when referenced by a valid invite.
|
||||
-- This is needed so the invite page can display the org name to non-members.
|
||||
CREATE POLICY "Anyone can view org via valid invite" ON public.organizations
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.org_invites oi
|
||||
WHERE oi.org_id = organizations.id
|
||||
AND oi.accepted_at IS NULL
|
||||
AND (oi.expires_at IS NULL OR oi.expires_at > now())
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user