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
|
# Used to auto-provision Matrix accounts for users
|
||||||
MATRIX_ADMIN_TOKEN=
|
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",
|
"onboarding_skip": "Skip for now",
|
||||||
"invite_email_subject": "{orgName} — You're invited to join",
|
"invite_email_subject": "{orgName} — You're invited to join",
|
||||||
"invite_email_sent": "Invite email sent to {email}",
|
"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",
|
"onboarding_skip": "Jäta vahele",
|
||||||
"invite_email_subject": "{orgName} — Oled kutsutud liituma",
|
"invite_email_subject": "{orgName} — Oled kutsutud liituma",
|
||||||
"invite_email_sent": "Kutse e-kiri saadetud aadressile {email}",
|
"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 showMemberModal = $state(false);
|
||||||
let selectedMember = $state<Member | null>(null);
|
let selectedMember = $state<Member | null>(null);
|
||||||
let selectedMemberRole = $state("");
|
let selectedMemberRole = $state("");
|
||||||
|
let isTransferring = $state(false);
|
||||||
|
|
||||||
|
const currentUserRole = $derived(
|
||||||
|
members.find((m) => m.user_id === userId)?.role ?? "viewer",
|
||||||
|
);
|
||||||
|
|
||||||
async function sendInvite() {
|
async function sendInvite() {
|
||||||
if (!inviteEmail.trim()) return;
|
if (!inviteEmail.trim()) return;
|
||||||
@@ -181,6 +186,65 @@
|
|||||||
members = members.filter((m) => m.id !== selectedMember!.id);
|
members = members.filter((m) => m.id !== selectedMember!.id);
|
||||||
showMemberModal = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4 max-w-2xl">
|
<div class="space-y-4 max-w-2xl">
|
||||||
@@ -411,13 +475,27 @@
|
|||||||
{ value: "admin", label: m.role_admin() },
|
{ value: "admin", label: m.role_admin() },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
<div class="flex items-center justify-between">
|
||||||
type="button"
|
<button
|
||||||
class="text-[11px] text-error hover:underline self-start"
|
type="button"
|
||||||
onclick={removeMember}
|
class="text-[11px] text-error hover:underline"
|
||||||
>
|
onclick={removeMember}
|
||||||
{m.settings_members_remove()}
|
>
|
||||||
</button>
|
{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
|
<div
|
||||||
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
|
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',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: `${orgName} <onboarding@resend.dev>`,
|
from: `${orgName} <${env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'}>`,
|
||||||
to: [email],
|
to: [email],
|
||||||
subject: `${orgName} — You're invited to join`,
|
subject: `${orgName} — You're invited to join`,
|
||||||
html: buildInviteEmailHtml(orgName, role, inviteUrl),
|
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;
|
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 {
|
return {
|
||||||
invite: {
|
invite: {
|
||||||
id: invite.id,
|
id: invite.id,
|
||||||
|
|||||||
@@ -135,11 +135,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title
|
{#if data.invite?.org}
|
||||||
>{data.invite?.org.name
|
<title>{m.invite_title()} | {data.invite.org.name}</title>
|
||||||
? `${m.invite_title()} | ${data.invite.org.name}`
|
{:else}
|
||||||
: m.invite_title()}</title
|
<title>{m.invite_title()}</title>
|
||||||
>
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-background flex items-center justify-center p-4">
|
<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