diff --git a/.env.example b/.env.example index 7e22c96..824c556 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file +# 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= \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index c1d57b7..b3e6a43 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" -} \ No newline at end of file + "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}" +} diff --git a/messages/et.json b/messages/et.json index 95ee853..30678b3 100644 --- a/messages/et.json +++ b/messages/et.json @@ -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}" } diff --git a/src/lib/components/settings/SettingsMembers.svelte b/src/lib/components/settings/SettingsMembers.svelte index 46b39de..3023ca5 100644 --- a/src/lib/components/settings/SettingsMembers.svelte +++ b/src/lib/components/settings/SettingsMembers.svelte @@ -71,6 +71,11 @@ let showMemberModal = $state(false); let selectedMember = $state(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 }), + ); + }
@@ -411,13 +475,27 @@ { value: "admin", label: m.role_admin() }, ]} /> - +
+ + {#if currentUserRole === "owner"} + + {/if} +
diff --git a/src/routes/api/send-invite-email/+server.ts b/src/routes/api/send-invite-email/+server.ts index cf68f8b..8e00fb5 100644 --- a/src/routes/api/send-invite-email/+server.ts +++ b/src/routes/api/send-invite-email/+server.ts @@ -30,7 +30,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - from: `${orgName} `, + from: `${orgName} <${env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'}>`, to: [email], subject: `${orgName} — You're invited to join`, html: buildInviteEmailHtml(orgName, role, inviteUrl), diff --git a/src/routes/invite/[token]/+page.server.ts b/src/routes/invite/[token]/+page.server.ts index 1172056..2a7ed30 100644 --- a/src/routes/invite/[token]/+page.server.ts +++ b/src/routes/invite/[token]/+page.server.ts @@ -34,6 +34,13 @@ export const load: PageServerLoad = async ({ params, locals }) => { const org = (invite as Record).organizations as { id: string; name: string; slug: string } | null; + if (!org) { + return { + error: 'Invalid or expired invite link', + token + }; + } + return { invite: { id: invite.id, diff --git a/src/routes/invite/[token]/+page.svelte b/src/routes/invite/[token]/+page.svelte index e381a78..e9ffbab 100644 --- a/src/routes/invite/[token]/+page.svelte +++ b/src/routes/invite/[token]/+page.svelte @@ -135,11 +135,11 @@ - {data.invite?.org.name - ? `${m.invite_title()} | ${data.invite.org.name}` - : m.invite_title()} + {#if data.invite?.org} + {m.invite_title()} | {data.invite.org.name} + {:else} + {m.invite_title()} + {/if}
diff --git a/supabase/migrations/057_invite_org_rls.sql b/supabase/migrations/057_invite_org_rls.sql new file mode 100644 index 0000000..bbbf7a9 --- /dev/null +++ b/supabase/migrations/057_invite_org_rls.sql @@ -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()) + ) + );