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