New features user management Google Calendar integration
This commit is contained in:
@@ -31,6 +31,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
|
||||
return {
|
||||
org,
|
||||
role: membership.role
|
||||
role: membership.role,
|
||||
userRole: membership.role
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
role: string;
|
||||
userRole: string;
|
||||
};
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { data, children }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
const isAdmin = $derived(
|
||||
data.userRole === "owner" || data.userRole === "admin",
|
||||
);
|
||||
|
||||
const navItems = $derived([
|
||||
{ href: `/${data.org.slug}`, label: "Overview", icon: "home" },
|
||||
{
|
||||
href: `/${data.org.slug}/documents`,
|
||||
@@ -25,12 +30,17 @@
|
||||
label: "Calendar",
|
||||
icon: "calendar",
|
||||
},
|
||||
{
|
||||
href: `/${data.org.slug}/settings`,
|
||||
label: "Settings",
|
||||
icon: "settings",
|
||||
},
|
||||
];
|
||||
// Only show settings for admins
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
href: `/${data.org.slug}/settings`,
|
||||
label: "Settings",
|
||||
icon: "settings",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return $page.url.pathname === href;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org } = await parent();
|
||||
const { org, userRole } = await parent();
|
||||
const { supabase } = locals;
|
||||
|
||||
// Fetch events for current month ± 1 month
|
||||
@@ -18,6 +18,7 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
.order('start_time');
|
||||
|
||||
return {
|
||||
events: events ?? []
|
||||
events: events ?? [],
|
||||
userRole
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { Button, Modal, Input, Textarea } from "$lib/components/ui";
|
||||
import { Calendar } from "$lib/components/calendar";
|
||||
import { createEvent } from "$lib/api/calendar";
|
||||
import {
|
||||
getCalendarSubscribeUrl,
|
||||
type GoogleCalendarEvent,
|
||||
} from "$lib/api/google-calendar";
|
||||
import type { CalendarEvent } from "$lib/supabase/types";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
@@ -12,6 +16,7 @@
|
||||
org: { id: string; name: string; slug: string };
|
||||
events: CalendarEvent[];
|
||||
user: { id: string } | null;
|
||||
userRole?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +25,17 @@
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let events = $state(data.events);
|
||||
let googleEvents = $state<CalendarEvent[]>([]);
|
||||
let isOrgCalendarConnected = $state(false);
|
||||
let isLoadingGoogle = $state(false);
|
||||
let orgCalendarId = $state<string | null>(null);
|
||||
let orgCalendarName = $state<string | null>(null);
|
||||
|
||||
const isAdmin = $derived(
|
||||
data.userRole === "owner" || data.userRole === "admin",
|
||||
);
|
||||
|
||||
const allEvents = $derived([...events, ...googleEvents]);
|
||||
let showCreateModal = $state(false);
|
||||
let showEventModal = $state(false);
|
||||
let selectedEvent = $state<CalendarEvent | null>(null);
|
||||
@@ -103,11 +119,100 @@
|
||||
const end = new Date(event.end_time);
|
||||
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadGoogleCalendarEvents();
|
||||
});
|
||||
|
||||
async function loadGoogleCalendarEvents() {
|
||||
isLoadingGoogle = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/google-calendar/events?org_id=${data.org.id}`,
|
||||
);
|
||||
const result = await res.json();
|
||||
|
||||
// Check if calendar is connected (even if no events)
|
||||
if (res.ok && result.calendar_id) {
|
||||
isOrgCalendarConnected = true;
|
||||
orgCalendarId = result.calendar_id;
|
||||
orgCalendarName = result.calendar_name;
|
||||
|
||||
if (result.events && result.events.length > 0) {
|
||||
googleEvents = result.events.map(
|
||||
(ge: GoogleCalendarEvent) => ({
|
||||
id: `google-${ge.id}`,
|
||||
org_id: data.org.id,
|
||||
title: ge.summary || "(No title)",
|
||||
description: ge.description ?? null,
|
||||
start_time:
|
||||
ge.start.dateTime ||
|
||||
`${ge.start.date}T00:00:00`,
|
||||
end_time:
|
||||
ge.end.dateTime || `${ge.end.date}T23:59:59`,
|
||||
all_day: !ge.start.dateTime,
|
||||
color: "#4285f4",
|
||||
recurrence: null,
|
||||
created_by: data.user?.id ?? "",
|
||||
created_at: new Date().toISOString(),
|
||||
}),
|
||||
) as CalendarEvent[];
|
||||
}
|
||||
} else if (result.error) {
|
||||
console.error("Calendar API error:", result.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load Google events:", e);
|
||||
}
|
||||
isLoadingGoogle = false;
|
||||
}
|
||||
|
||||
function subscribeToCalendar() {
|
||||
if (!orgCalendarId) return;
|
||||
const url = getCalendarSubscribeUrl(orgCalendarId);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 h-full overflow-auto">
|
||||
<header class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-light">Calendar</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-2xl font-bold text-light">Calendar</h1>
|
||||
{#if isOrgCalendarConnected}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-500/10 text-blue-400 rounded-lg"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
</svg>
|
||||
{orgCalendarName ?? "Google Calendar"}
|
||||
{#if isLoadingGoogle}
|
||||
<span class="animate-spin">⟳</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-green-500/10 text-green-400 rounded-lg hover:bg-green-500/20 transition-colors"
|
||||
onclick={subscribeToCalendar}
|
||||
title="Add to your Google Calendar"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
@@ -124,7 +229,7 @@
|
||||
</header>
|
||||
|
||||
<Calendar
|
||||
{events}
|
||||
events={allEvents}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
@@ -208,32 +313,130 @@
|
||||
title={selectedEvent?.title ?? "Event"}
|
||||
>
|
||||
{#if selectedEvent}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="space-y-4">
|
||||
<!-- Date and Time -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {selectedEvent.color ?? '#6366f1'}"
|
||||
></div>
|
||||
<span class="text-light/70"
|
||||
>{formatEventTime(selectedEvent)}</span
|
||||
class="w-8 h-8 rounded-lg bg-light/10 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-light/70"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-light font-medium">
|
||||
{new Date(selectedEvent.start_time).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p class="text-light/60 text-sm">
|
||||
{formatEventTime(selectedEvent)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if selectedEvent.description}
|
||||
<p class="text-light/80">{selectedEvent.description}</p>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-light/10 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-light/70"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="17" y1="10" x2="3" y2="10" />
|
||||
<line x1="21" y1="6" x2="3" y2="6" />
|
||||
<line x1="21" y1="14" x2="3" y2="14" />
|
||||
<line x1="17" y1="18" x2="3" y2="18" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-light/80 text-sm whitespace-pre-wrap">
|
||||
{selectedEvent.description}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-light/40">
|
||||
{new Date(selectedEvent.start_time).toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<!-- Color indicator -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-light/10 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full"
|
||||
style="background-color: {selectedEvent.color ??
|
||||
'#6366f1'}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-light/60 text-sm">
|
||||
{selectedEvent.id.startsWith("google-")
|
||||
? "Google Calendar Event"
|
||||
: "Local Event"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Google Calendar link -->
|
||||
{#if selectedEvent.id.startsWith("google-") && orgCalendarId}
|
||||
<div class="pt-3 border-t border-light/10">
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/eventedit/{selectedEvent.id.replace(
|
||||
'google-',
|
||||
'',
|
||||
)}?cid={encodeURIComponent(orgCalendarId)}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Open in Google Calendar
|
||||
</a>
|
||||
<p class="text-xs text-light/40 mt-2">
|
||||
Edit this event directly in Google Calendar
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
61
src/routes/[orgSlug]/settings/+page.server.ts
Normal file
61
src/routes/[orgSlug]/settings/+page.server.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { org, userRole } = await parent();
|
||||
|
||||
// Only admins and owners can access settings
|
||||
if (userRole !== 'owner' && userRole !== 'admin') {
|
||||
redirect(303, `/${(org as any).slug}`);
|
||||
}
|
||||
|
||||
const orgId = (org as any).id;
|
||||
|
||||
// Get org members with profiles
|
||||
const { data: members } = await locals.supabase
|
||||
.from('org_members')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
role,
|
||||
role_id,
|
||||
created_at,
|
||||
profiles:user_id (
|
||||
id,
|
||||
email,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('org_id', orgId);
|
||||
|
||||
// Get org roles
|
||||
const { data: roles } = await locals.supabase
|
||||
.from('org_roles')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.order('position');
|
||||
|
||||
// Get pending invites
|
||||
const { data: invites } = await locals.supabase
|
||||
.from('org_invites')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.is('accepted_at', null)
|
||||
.gt('expires_at', new Date().toISOString());
|
||||
|
||||
// Get org Google Calendar connection
|
||||
const { data: orgCalendar } = await locals.supabase
|
||||
.from('org_google_calendars')
|
||||
.select('*')
|
||||
.eq('org_id', orgId)
|
||||
.single();
|
||||
|
||||
return {
|
||||
members: members ?? [],
|
||||
roles: roles ?? [],
|
||||
invites: invites ?? [],
|
||||
orgCalendar,
|
||||
userRole
|
||||
};
|
||||
};
|
||||
1160
src/routes/[orgSlug]/settings/+page.svelte
Normal file
1160
src/routes/[orgSlug]/settings/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { exchangeCodeForTokens } from '$lib/api/google-calendar';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const stateParam = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error || !code || !stateParam) {
|
||||
redirect(303, '/?error=google_auth_failed');
|
||||
}
|
||||
|
||||
let state: { orgSlug: string; userId: string };
|
||||
try {
|
||||
state = JSON.parse(decodeURIComponent(stateParam));
|
||||
} catch {
|
||||
redirect(303, '/?error=invalid_state');
|
||||
}
|
||||
|
||||
const redirectUri = `${url.origin}/api/google-calendar/callback`;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri);
|
||||
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
||||
// Store tokens in database
|
||||
await locals.supabase
|
||||
.from('google_calendar_connections')
|
||||
.upsert({
|
||||
user_id: state.userId,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
token_expires_at: expiresAt.toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'user_id' });
|
||||
|
||||
redirect(303, `/${state.orgSlug}/calendar?connected=true`);
|
||||
} catch (err) {
|
||||
console.error('Google Calendar OAuth error:', err);
|
||||
redirect(303, `/${state.orgSlug}/calendar?error=token_exchange_failed`);
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGoogleAuthUrl } from '$lib/api/google-calendar';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const { session } = await locals.safeGetSession();
|
||||
|
||||
if (!session) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
const orgSlug = url.searchParams.get('org');
|
||||
if (!orgSlug) {
|
||||
redirect(303, '/');
|
||||
}
|
||||
|
||||
const redirectUri = `${url.origin}/api/google-calendar/callback`;
|
||||
const state = JSON.stringify({ orgSlug, userId: session.user.id });
|
||||
const authUrl = getGoogleAuthUrl(redirectUri, encodeURIComponent(state));
|
||||
|
||||
redirect(303, authUrl);
|
||||
};
|
||||
60
src/routes/api/google-calendar/events/+server.ts
Normal file
60
src/routes/api/google-calendar/events/+server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { GOOGLE_API_KEY } from '$env/static/private';
|
||||
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
|
||||
|
||||
// Fetch events from a public Google Calendar
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
|
||||
if (!orgId) {
|
||||
return json({ error: 'org_id required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!GOOGLE_API_KEY) {
|
||||
return json({ error: 'Google API key not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the org's calendar ID from database
|
||||
const { data: orgCal, error: dbError } = await locals.supabase
|
||||
.from('org_google_calendars')
|
||||
.select('calendar_id, calendar_name')
|
||||
.eq('org_id', orgId)
|
||||
.single();
|
||||
|
||||
if (dbError) {
|
||||
console.error('DB error fetching calendar:', dbError);
|
||||
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!orgCal) {
|
||||
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log('Fetching events for calendar:', (orgCal as any).calendar_id);
|
||||
|
||||
// Fetch events for the next 3 months
|
||||
const now = new Date();
|
||||
const timeMin = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
||||
|
||||
const events = await fetchPublicCalendarEvents(
|
||||
(orgCal as any).calendar_id,
|
||||
GOOGLE_API_KEY,
|
||||
timeMin,
|
||||
timeMax
|
||||
);
|
||||
|
||||
console.log('Fetched', events.length, 'events');
|
||||
|
||||
return json({
|
||||
events,
|
||||
calendar_id: (orgCal as any).calendar_id,
|
||||
calendar_name: (orgCal as any).calendar_name
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch calendar events:', err);
|
||||
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user