Added invite page and fixed inviting logic

master
AlacrisDevs 2 days ago
parent 6ec6b0753f
commit 424a03f177
  1. 114
      src/lib/supabase/types.ts
  2. 2
      src/routes/auth/callback/+server.ts
  3. 47
      src/routes/invite/[token]/+page.server.ts
  4. 188
      src/routes/invite/[token]/+page.svelte
  5. 12
      src/routes/login/+page.svelte

@ -34,7 +34,8 @@ export interface Database {
id: string; id: string;
org_id: string; org_id: string;
user_id: string; user_id: string;
role: 'owner' | 'admin' | 'editor' | 'viewer'; role: string;
role_id: string | null;
invited_at: string; invited_at: string;
joined_at: string | null; joined_at: string | null;
}; };
@ -42,7 +43,8 @@ export interface Database {
id?: string; id?: string;
org_id: string; org_id: string;
user_id: string; user_id: string;
role: 'owner' | 'admin' | 'editor' | 'viewer'; role?: string;
role_id?: string | null;
invited_at?: string; invited_at?: string;
joined_at?: string | null; joined_at?: string | null;
}; };
@ -50,7 +52,8 @@ export interface Database {
id?: string; id?: string;
org_id?: string; org_id?: string;
user_id?: string; user_id?: string;
role?: 'owner' | 'admin' | 'editor' | 'viewer'; role?: string;
role_id?: string | null;
invited_at?: string; invited_at?: string;
joined_at?: string | null; joined_at?: string | null;
}; };
@ -240,6 +243,111 @@ export interface Database {
status?: 'pending' | 'accepted' | 'declined'; status?: 'pending' | 'accepted' | 'declined';
}; };
}; };
org_roles: {
Row: {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
org_id: string;
name: string;
color?: string;
permissions?: string[];
is_default?: boolean;
is_system?: boolean;
position?: number;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
org_id?: string;
name?: string;
color?: string;
permissions?: string[];
is_default?: boolean;
is_system?: boolean;
position?: number;
created_at?: string;
updated_at?: string;
};
};
org_invites: {
Row: {
id: string;
org_id: string;
email: string;
role_id: string | null;
role: string;
invited_by: string;
token: string;
expires_at: string;
accepted_at: string | null;
created_at: string;
};
Insert: {
id?: string;
org_id: string;
email: string;
role_id?: string | null;
role?: string;
invited_by: string;
token?: string;
expires_at?: string;
accepted_at?: string | null;
created_at?: string;
};
Update: {
id?: string;
org_id?: string;
email?: string;
role_id?: string | null;
role?: string;
invited_by?: string;
token?: string;
expires_at?: string;
accepted_at?: string | null;
created_at?: string;
};
};
org_google_calendars: {
Row: {
id: string;
org_id: string;
calendar_id: string;
calendar_name: string | null;
connected_by: string;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
org_id: string;
calendar_id: string;
calendar_name?: string | null;
connected_by: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
org_id?: string;
calendar_id?: string;
calendar_name?: string | null;
connected_by?: string;
created_at?: string;
updated_at?: string;
};
};
profiles: { profiles: {
Row: { Row: {
id: string; id: string;

@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, locals }) => { export const GET: RequestHandler = async ({ url, locals }) => {
const code = url.searchParams.get('code'); const code = url.searchParams.get('code');
const next = url.searchParams.get('next') ?? '/'; const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/';
if (code) { if (code) {
const { error } = await locals.supabase.auth.exchangeCodeForSession(code); const { error } = await locals.supabase.auth.exchangeCodeForSession(code);

@ -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>

@ -2,6 +2,7 @@
import { Button, Input, Card } from "$lib/components/ui"; import { Button, Input, Card } from "$lib/components/ui";
import { createClient } from "$lib/supabase"; import { createClient } from "$lib/supabase";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores";
let email = $state(""); let email = $state("");
let password = $state(""); let password = $state("");
@ -11,6 +12,9 @@
const supabase = createClient(); const supabase = createClient();
// Get redirect URL from query params (for invite flow)
const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/");
async function handleSubmit() { async function handleSubmit() {
if (!email || !password) { if (!email || !password) {
error = "Please fill in all fields"; error = "Please fill in all fields";
@ -38,7 +42,7 @@
}); });
if (authError) throw authError; if (authError) throw authError;
} }
goto("/"); goto(redirectUrl);
} catch (e: unknown) { } catch (e: unknown) {
error = e instanceof Error ? e.message : "An error occurred"; error = e instanceof Error ? e.message : "An error occurred";
} finally { } finally {
@ -47,10 +51,14 @@
} }
async function handleOAuth(provider: "google" | "github") { 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({ const { error: authError } = await supabase.auth.signInWithOAuth({
provider, provider,
options: { options: {
redirectTo: `${window.location.origin}/auth/callback`, redirectTo: callbackUrl,
}, },
}); });
if (authError) { if (authError) {

Loading…
Cancel
Save