Added invite page and fixed inviting logic
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
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 { 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user