parent
6ec6b0753f
commit
424a03f177
5 changed files with 357 additions and 6 deletions
@ -0,0 +1,47 @@ |
||||
import type { PageServerLoad } from './$types'; |
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => { |
||||
const { token } = params; |
||||
|
||||
// Get invite details
|
||||
const { data: invite, error } = await locals.supabase |
||||
.from('org_invites') |
||||
.select(` |
||||
*, |
||||
organizations (id, name, slug) |
||||
`)
|
||||
.eq('token', token) |
||||
.is('accepted_at', null) |
||||
.single(); |
||||
|
||||
if (error || !invite) { |
||||
return { |
||||
error: 'Invalid or expired invite link', |
||||
token |
||||
}; |
||||
} |
||||
|
||||
const inv = invite as any; |
||||
|
||||
// Check if invite is expired
|
||||
if (new Date(inv.expires_at) < new Date()) { |
||||
return { |
||||
error: 'This invite has expired', |
||||
token |
||||
}; |
||||
} |
||||
|
||||
// Get current user
|
||||
const { data: { user } } = await locals.supabase.auth.getUser(); |
||||
|
||||
return { |
||||
invite: { |
||||
id: inv.id, |
||||
email: inv.email, |
||||
role: inv.role, |
||||
org: inv.organizations |
||||
}, |
||||
user, |
||||
token |
||||
}; |
||||
}; |
||||
@ -0,0 +1,188 @@ |
||||
<script lang="ts"> |
||||
import { goto } from "$app/navigation"; |
||||
import { Button, Card } from "$lib/components/ui"; |
||||
import { getContext } from "svelte"; |
||||
import type { SupabaseClient } from "@supabase/supabase-js"; |
||||
|
||||
interface Props { |
||||
data: { |
||||
invite?: { |
||||
id: string; |
||||
email: string; |
||||
role: string; |
||||
org: { id: string; name: string; slug: string }; |
||||
}; |
||||
user: { id: string; email?: string } | null; |
||||
token: string; |
||||
error?: string; |
||||
}; |
||||
} |
||||
|
||||
let { data }: Props = $props(); |
||||
const supabase = getContext<SupabaseClient>("supabase"); |
||||
|
||||
let isAccepting = $state(false); |
||||
let error = $state(data.error || ""); |
||||
|
||||
async function acceptInvite() { |
||||
if (!data.invite || !data.user) return; |
||||
|
||||
// Check if user's email matches invite email |
||||
if ( |
||||
data.user.email?.toLowerCase() !== data.invite.email.toLowerCase() |
||||
) { |
||||
error = `This invite was sent to ${data.invite.email}. Please sign in with that email address.`; |
||||
return; |
||||
} |
||||
|
||||
isAccepting = true; |
||||
error = ""; |
||||
|
||||
try { |
||||
// Add user to org members |
||||
const { error: memberError } = await supabase |
||||
.from("org_members") |
||||
.insert({ |
||||
org_id: data.invite.org.id, |
||||
user_id: data.user.id, |
||||
role: data.invite.role, |
||||
}); |
||||
|
||||
if (memberError) { |
||||
if (memberError.code === "23505") { |
||||
// Already a member |
||||
error = "You're already a member of this organization."; |
||||
} else { |
||||
error = "Failed to join organization. Please try again."; |
||||
console.error(memberError); |
||||
} |
||||
isAccepting = false; |
||||
return; |
||||
} |
||||
|
||||
// Mark invite as accepted |
||||
await supabase |
||||
.from("org_invites") |
||||
.update({ accepted_at: new Date().toISOString() }) |
||||
.eq("id", data.invite.id); |
||||
|
||||
// Redirect to org |
||||
goto(`/${data.invite.org.slug}`); |
||||
} catch (e) { |
||||
error = "Something went wrong. Please try again."; |
||||
console.error(e); |
||||
isAccepting = false; |
||||
} |
||||
} |
||||
|
||||
function goToLogin() { |
||||
// Store the invite URL to redirect back after login |
||||
const returnUrl = `/invite/${data.token}`; |
||||
goto(`/login?redirect=${encodeURIComponent(returnUrl)}`); |
||||
} |
||||
|
||||
function goToSignup() { |
||||
const returnUrl = `/invite/${data.token}`; |
||||
goto( |
||||
`/signup?redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`, |
||||
); |
||||
} |
||||
</script> |
||||
|
||||
<div class="min-h-screen bg-dark flex items-center justify-center p-4"> |
||||
<div |
||||
class="w-full max-w-md bg-dark-light rounded-xl border border-light/10" |
||||
> |
||||
<div class="p-6 text-center"> |
||||
{#if data.error} |
||||
<!-- Invalid/Expired Invite --> |
||||
<div |
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/20 flex items-center justify-center" |
||||
> |
||||
<svg |
||||
class="w-8 h-8 text-red-400" |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
> |
||||
<circle cx="12" cy="12" r="10" /> |
||||
<line x1="15" y1="9" x2="9" y2="15" /> |
||||
<line x1="9" y1="9" x2="15" y2="15" /> |
||||
</svg> |
||||
</div> |
||||
<h1 class="text-xl font-bold text-light mb-2"> |
||||
Invalid Invite |
||||
</h1> |
||||
<p class="text-light/60 mb-6">{data.error}</p> |
||||
<Button onclick={() => goto("/")}>Go Home</Button> |
||||
{:else if data.invite} |
||||
<!-- Valid Invite --> |
||||
<div |
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/20 flex items-center justify-center" |
||||
> |
||||
<svg |
||||
class="w-8 h-8 text-primary" |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
> |
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /> |
||||
<circle cx="9" cy="7" r="4" /> |
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /> |
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> |
||||
</svg> |
||||
</div> |
||||
|
||||
<h1 class="text-xl font-bold text-light mb-2"> |
||||
You're Invited! |
||||
</h1> |
||||
<p class="text-light/60 mb-1">You've been invited to join</p> |
||||
<p class="text-2xl font-bold text-primary mb-1"> |
||||
{data.invite.org.name} |
||||
</p> |
||||
<p class="text-light/50 text-sm mb-6">as {data.invite.role}</p> |
||||
|
||||
{#if error} |
||||
<div |
||||
class="p-3 mb-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm" |
||||
> |
||||
{error} |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if data.user} |
||||
<!-- User is logged in --> |
||||
<p class="text-light/60 text-sm mb-4"> |
||||
Signed in as <strong class="text-light" |
||||
>{data.user.email}</strong |
||||
> |
||||
</p> |
||||
<div class="w-full"> |
||||
<Button onclick={acceptInvite} loading={isAccepting}> |
||||
Accept Invite & Join |
||||
</Button> |
||||
</div> |
||||
<p class="text-light/40 text-xs mt-3"> |
||||
Wrong account? <a |
||||
href="/logout" |
||||
class="text-primary hover:underline">Sign out</a |
||||
> |
||||
</p> |
||||
{:else} |
||||
<!-- User not logged in --> |
||||
<p class="text-light/60 text-sm mb-4"> |
||||
Sign in or create an account to accept this invite. |
||||
</p> |
||||
<div class="flex flex-col gap-2"> |
||||
<Button onclick={goToLogin}>Sign In</Button> |
||||
<Button onclick={goToSignup} variant="ghost" |
||||
>Create Account</Button |
||||
> |
||||
</div> |
||||
{/if} |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
Loading…
Reference in new issue