Added invite page and fixed inviting logic
This commit is contained in:
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const next = url.searchParams.get('next') ?? '/';
|
||||
const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/';
|
||||
|
||||
if (code) {
|
||||
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
|
||||
|
||||
47
src/routes/invite/[token]/+page.server.ts
Normal file
47
src/routes/invite/[token]/+page.server.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
188
src/routes/invite/[token]/+page.svelte
Normal file
188
src/routes/invite/[token]/+page.svelte
Normal file
@@ -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>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Button, Input, Card } from "$lib/components/ui";
|
||||
import { createClient } from "$lib/supabase";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
@@ -11,6 +12,9 @@
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
// Get redirect URL from query params (for invite flow)
|
||||
const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/");
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !password) {
|
||||
error = "Please fill in all fields";
|
||||
@@ -38,7 +42,7 @@
|
||||
});
|
||||
if (authError) throw authError;
|
||||
}
|
||||
goto("/");
|
||||
goto(redirectUrl);
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : "An error occurred";
|
||||
} finally {
|
||||
@@ -47,10 +51,14 @@
|
||||
}
|
||||
|
||||
async function handleOAuth(provider: "google" | "github") {
|
||||
const callbackUrl =
|
||||
redirectUrl !== "/"
|
||||
? `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirectUrl)}`
|
||||
: `${window.location.origin}/auth/callback`;
|
||||
const { error: authError } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
redirectTo: callbackUrl,
|
||||
},
|
||||
});
|
||||
if (authError) {
|
||||
|
||||
Reference in New Issue
Block a user