This commit is contained in:
AlacrisDevs
2026-02-09 12:00:40 +02:00
parent 9885f9459d
commit 71bf7b9057
8 changed files with 126 additions and 17 deletions

View File

@@ -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=

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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"
>

View File

@@ -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),

View File

@@ -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,

View File

@@ -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">

View 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())
)
);