New features user management Google Calendar integration
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
|
||||||
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
|
||||||
|
|
||||||
# Google Calendar Integration (optional)
|
|
||||||
VITE_GOOGLE_CLIENT_ID=your-google-client-id
|
|
||||||
VITE_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
|
||||||
@@ -1,264 +1,100 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
// Google Calendar functions for PUBLIC calendars (no OAuth needed)
|
||||||
import type { Database } from '$lib/supabase/types';
|
// Just needs a Google API key and a public calendar ID
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
export interface GoogleCalendarEvent {
|
||||||
const GOOGLE_CLIENT_SECRET = import.meta.env.VITE_GOOGLE_CLIENT_SECRET;
|
|
||||||
|
|
||||||
interface GoogleTokens {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GoogleCalendarEvent {
|
|
||||||
id: string;
|
id: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
start: { dateTime?: string; date?: string };
|
start: { dateTime?: string; date?: string };
|
||||||
end: { dateTime?: string; date?: string };
|
end: { dateTime?: string; date?: string };
|
||||||
colorId?: string;
|
colorId?: string;
|
||||||
|
htmlLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGoogleAuthUrl(redirectUri: string, state: string): string {
|
/**
|
||||||
const params = new URLSearchParams({
|
* Extract calendar ID from various Google Calendar URL formats
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
* Supports:
|
||||||
redirect_uri: redirectUri,
|
* - Direct calendar ID (email format)
|
||||||
response_type: 'code',
|
* - Shareable URL with cid parameter (base64 encoded)
|
||||||
scope: 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
|
* - Public URL with calendar ID in path
|
||||||
access_type: 'offline',
|
*/
|
||||||
prompt: 'consent',
|
export function extractCalendarId(input: string): string | null {
|
||||||
state
|
if (!input) return null;
|
||||||
});
|
|
||||||
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise<GoogleTokens> {
|
// If it looks like an email/calendar ID already, return it
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
if (input.includes('@') && !input.includes('http')) {
|
||||||
method: 'POST',
|
return input.trim();
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
code,
|
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
|
||||||
client_secret: GOOGLE_CLIENT_SECRET,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
grant_type: 'authorization_code'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to exchange code for tokens');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshAccessToken(refreshToken: string): Promise<GoogleTokens> {
|
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: GOOGLE_CLIENT_ID,
|
|
||||||
client_secret: GOOGLE_CLIENT_SECRET,
|
|
||||||
grant_type: 'refresh_token'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to refresh access token');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getValidAccessToken(
|
|
||||||
supabase: SupabaseClient<Database>,
|
|
||||||
userId: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
const { data: connection } = await supabase
|
|
||||||
.from('google_calendar_connections')
|
|
||||||
.select('*')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!connection) return null;
|
|
||||||
|
|
||||||
const expiresAt = new Date(connection.token_expires_at);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Refresh if expires within 5 minutes
|
|
||||||
if (expiresAt.getTime() - now.getTime() < 5 * 60 * 1000) {
|
|
||||||
try {
|
try {
|
||||||
const tokens = await refreshAccessToken(connection.refresh_token);
|
const url = new URL(input);
|
||||||
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
|
||||||
|
|
||||||
await supabase
|
// Check for cid parameter (base64 encoded calendar ID)
|
||||||
.from('google_calendar_connections')
|
const cid = url.searchParams.get('cid');
|
||||||
.update({
|
if (cid) {
|
||||||
access_token: tokens.access_token,
|
// Decode base64 to get calendar ID
|
||||||
token_expires_at: newExpiresAt.toISOString(),
|
try {
|
||||||
updated_at: new Date().toISOString()
|
return atob(cid);
|
||||||
})
|
|
||||||
.eq('user_id', userId);
|
|
||||||
|
|
||||||
return tokens.access_token;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return cid; // Maybe it's not encoded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.access_token;
|
// Check for src parameter
|
||||||
|
const src = url.searchParams.get('src');
|
||||||
|
if (src) return src;
|
||||||
|
|
||||||
|
// Check path for calendar ID in ical URL format
|
||||||
|
const icalMatch = input.match(/calendar\/ical\/([^/]+)/);
|
||||||
|
if (icalMatch) return decodeURIComponent(icalMatch[1]);
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
// Not a valid URL, maybe it's just a calendar ID
|
||||||
|
if (input.includes('@')) {
|
||||||
|
return input.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGoogleCalendarEvents(
|
/**
|
||||||
accessToken: string,
|
* Generate a subscribe URL for a public Google Calendar
|
||||||
calendarId: string = 'primary',
|
*/
|
||||||
|
export function getCalendarSubscribeUrl(calendarId: string): string {
|
||||||
|
const encoded = btoa(calendarId);
|
||||||
|
return `https://calendar.google.com/calendar/u/0?cid=${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch events from a PUBLIC Google Calendar
|
||||||
|
* Requires: Calendar must be set to "Make available to public" in Google Calendar settings
|
||||||
|
*/
|
||||||
|
export async function fetchPublicCalendarEvents(
|
||||||
|
calendarId: string,
|
||||||
|
apiKey: string,
|
||||||
timeMin: Date,
|
timeMin: Date,
|
||||||
timeMax: Date
|
timeMax: Date
|
||||||
): Promise<GoogleCalendarEvent[]> {
|
): Promise<GoogleCalendarEvent[]> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
key: apiKey,
|
||||||
timeMin: timeMin.toISOString(),
|
timeMin: timeMin.toISOString(),
|
||||||
timeMax: timeMax.toISOString(),
|
timeMax: timeMax.toISOString(),
|
||||||
singleEvents: 'true',
|
singleEvents: 'true',
|
||||||
orderBy: 'startTime'
|
orderBy: 'startTime',
|
||||||
|
maxResults: '250'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` }
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch Google Calendar events');
|
const error = await response.text();
|
||||||
|
console.error('Google Calendar API error:', error);
|
||||||
|
throw new Error('Failed to fetch calendar events. Make sure the calendar is set to public.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGoogleCalendarEvent(
|
|
||||||
accessToken: string,
|
|
||||||
calendarId: string = 'primary',
|
|
||||||
event: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
allDay?: boolean;
|
|
||||||
}
|
|
||||||
): Promise<GoogleCalendarEvent> {
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
summary: event.title,
|
|
||||||
description: event.description
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.allDay) {
|
|
||||||
const startDate = event.startTime.split('T')[0];
|
|
||||||
const endDate = event.endTime.split('T')[0];
|
|
||||||
body.start = { date: startDate };
|
|
||||||
body.end = { date: endDate };
|
|
||||||
} else {
|
|
||||||
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
|
||||||
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to create Google Calendar event');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateGoogleCalendarEvent(
|
|
||||||
accessToken: string,
|
|
||||||
calendarId: string = 'primary',
|
|
||||||
eventId: string,
|
|
||||||
event: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
allDay?: boolean;
|
|
||||||
}
|
|
||||||
): Promise<GoogleCalendarEvent> {
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
summary: event.title,
|
|
||||||
description: event.description
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.allDay) {
|
|
||||||
const startDate = event.startTime.split('T')[0];
|
|
||||||
const endDate = event.endTime.split('T')[0];
|
|
||||||
body.start = { date: startDate };
|
|
||||||
body.end = { date: endDate };
|
|
||||||
} else {
|
|
||||||
body.start = { dateTime: event.startTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
|
||||||
body.end = { dateTime: event.endTime, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to update Google Calendar event');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteGoogleCalendarEvent(
|
|
||||||
accessToken: string,
|
|
||||||
calendarId: string = 'primary',
|
|
||||||
eventId: string
|
|
||||||
): Promise<void> {
|
|
||||||
const response = await fetch(
|
|
||||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok && response.status !== 404) {
|
|
||||||
throw new Error('Failed to delete Google Calendar event');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listGoogleCalendars(accessToken: string): Promise<{ id: string; summary: string }[]> {
|
|
||||||
const response = await fetch('https://www.googleapis.com/calendar/v3/users/me/calendarList', {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to list calendars');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return (data.items ?? []).map((cal: { id: string; summary: string }) => ({
|
|
||||||
id: cal.id,
|
|
||||||
summary: cal.summary
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
org,
|
org,
|
||||||
role: membership.role
|
role: membership.role,
|
||||||
|
userRole: membership.role
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,13 +6,18 @@
|
|||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
role: string;
|
role: string;
|
||||||
|
userRole: string;
|
||||||
};
|
};
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data, children }: Props = $props();
|
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}`, label: "Overview", icon: "home" },
|
||||||
{
|
{
|
||||||
href: `/${data.org.slug}/documents`,
|
href: `/${data.org.slug}/documents`,
|
||||||
@@ -25,12 +30,17 @@
|
|||||||
label: "Calendar",
|
label: "Calendar",
|
||||||
icon: "calendar",
|
icon: "calendar",
|
||||||
},
|
},
|
||||||
|
// Only show settings for admins
|
||||||
|
...(isAdmin
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
href: `/${data.org.slug}/settings`,
|
href: `/${data.org.slug}/settings`,
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
icon: "settings",
|
icon: "settings",
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
return $page.url.pathname === href;
|
return $page.url.pathname === href;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||||
const { org } = await parent();
|
const { org, userRole } = await parent();
|
||||||
const { supabase } = locals;
|
const { supabase } = locals;
|
||||||
|
|
||||||
// Fetch events for current month ± 1 month
|
// Fetch events for current month ± 1 month
|
||||||
@@ -18,6 +18,7 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
|
|||||||
.order('start_time');
|
.order('start_time');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events: events ?? []
|
events: events ?? [],
|
||||||
|
userRole
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import { Button, Modal, Input, Textarea } from "$lib/components/ui";
|
import { Button, Modal, Input, Textarea } from "$lib/components/ui";
|
||||||
import { Calendar } from "$lib/components/calendar";
|
import { Calendar } from "$lib/components/calendar";
|
||||||
import { createEvent } from "$lib/api/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 { CalendarEvent } from "$lib/supabase/types";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
@@ -12,6 +16,7 @@
|
|||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
user: { id: string } | null;
|
user: { id: string } | null;
|
||||||
|
userRole?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +25,17 @@
|
|||||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
let events = $state(data.events);
|
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 showCreateModal = $state(false);
|
||||||
let showEventModal = $state(false);
|
let showEventModal = $state(false);
|
||||||
let selectedEvent = $state<CalendarEvent | null>(null);
|
let selectedEvent = $state<CalendarEvent | null>(null);
|
||||||
@@ -103,11 +119,100 @@
|
|||||||
const end = new Date(event.end_time);
|
const end = new Date(event.end_time);
|
||||||
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="p-6 h-full overflow-auto">
|
<div class="p-6 h-full overflow-auto">
|
||||||
<header class="flex items-center justify-between mb-6">
|
<header class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
<h1 class="text-2xl font-bold text-light">Calendar</h1>
|
<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)}>
|
<Button onclick={() => (showCreateModal = true)}>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 mr-2"
|
class="w-4 h-4 mr-2"
|
||||||
@@ -124,7 +229,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Calendar
|
<Calendar
|
||||||
{events}
|
events={allEvents}
|
||||||
onDateClick={handleDateClick}
|
onDateClick={handleDateClick}
|
||||||
onEventClick={handleEventClick}
|
onEventClick={handleEventClick}
|
||||||
/>
|
/>
|
||||||
@@ -208,22 +313,34 @@
|
|||||||
title={selectedEvent?.title ?? "Event"}
|
title={selectedEvent?.title ?? "Event"}
|
||||||
>
|
>
|
||||||
{#if selectedEvent}
|
{#if selectedEvent}
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Date and Time -->
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="w-3 h-3 rounded-full"
|
class="w-8 h-8 rounded-lg bg-light/10 flex items-center justify-center flex-shrink-0"
|
||||||
style="background-color: {selectedEvent.color ?? '#6366f1'}"
|
|
||||||
></div>
|
|
||||||
<span class="text-light/70"
|
|
||||||
>{formatEventTime(selectedEvent)}</span
|
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
|
<div>
|
||||||
{#if selectedEvent.description}
|
<p class="text-light font-medium">
|
||||||
<p class="text-light/80">{selectedEvent.description}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p class="text-xs text-light/40">
|
|
||||||
{new Date(selectedEvent.start_time).toLocaleDateString(
|
{new Date(selectedEvent.start_time).toLocaleDateString(
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
@@ -234,6 +351,92 @@
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-light/60 text-sm">
|
||||||
|
{formatEventTime(selectedEvent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
{#if selectedEvent.description}
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Modal>
|
</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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
37
supabase/migrations/004_org_google_calendar.sql
Normal file
37
supabase/migrations/004_org_google_calendar.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- Organization-level Google Calendar (shared across all members)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS org_google_calendars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
calendar_id TEXT NOT NULL, -- Google Calendar ID (e.g., "abc123@group.calendar.google.com")
|
||||||
|
calendar_name TEXT,
|
||||||
|
connected_by UUID REFERENCES auth.users(id),
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NOT NULL,
|
||||||
|
token_expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_google_calendars_org ON org_google_calendars(org_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE org_google_calendars ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- All org members can view the org calendar connection
|
||||||
|
CREATE POLICY "Org members can view org calendar" ON org_google_calendars
|
||||||
|
FOR SELECT USING (EXISTS (
|
||||||
|
SELECT 1 FROM org_members om
|
||||||
|
WHERE om.org_id = org_google_calendars.org_id
|
||||||
|
AND om.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Only admins/owners can manage org calendar
|
||||||
|
CREATE POLICY "Admins can manage org calendar" ON org_google_calendars
|
||||||
|
FOR ALL USING (EXISTS (
|
||||||
|
SELECT 1 FROM org_members om
|
||||||
|
WHERE om.org_id = org_google_calendars.org_id
|
||||||
|
AND om.user_id = auth.uid()
|
||||||
|
AND om.role IN ('owner', 'admin')
|
||||||
|
));
|
||||||
177
supabase/migrations/005_roles_and_invites.sql
Normal file
177
supabase/migrations/005_roles_and_invites.sql
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
-- Custom Roles and Invite System
|
||||||
|
|
||||||
|
-- Permission types (similar to Google Drive)
|
||||||
|
-- viewer: Can view content
|
||||||
|
-- commenter: Can view and comment
|
||||||
|
-- editor: Can view, comment, and edit content
|
||||||
|
-- admin: Can manage members and settings
|
||||||
|
-- owner: Full control
|
||||||
|
|
||||||
|
-- Custom roles table (allows vanity roles with custom permissions)
|
||||||
|
CREATE TABLE IF NOT EXISTS org_roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT DEFAULT '#6366f1', -- For vanity display
|
||||||
|
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
is_default BOOLEAN DEFAULT false, -- If this is a default role for new members
|
||||||
|
is_system BOOLEAN DEFAULT false, -- System roles can't be deleted
|
||||||
|
position INTEGER DEFAULT 0, -- For ordering
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE(org_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Available permissions
|
||||||
|
COMMENT ON COLUMN org_roles.permissions IS 'Array of permission strings:
|
||||||
|
documents.view, documents.create, documents.edit, documents.delete,
|
||||||
|
kanban.view, kanban.create, kanban.edit, kanban.delete,
|
||||||
|
calendar.view, calendar.create, calendar.edit, calendar.delete,
|
||||||
|
members.view, members.invite, members.manage, members.remove,
|
||||||
|
roles.view, roles.create, roles.edit, roles.delete,
|
||||||
|
settings.view, settings.edit,
|
||||||
|
org.delete';
|
||||||
|
|
||||||
|
-- Update org_members to reference custom roles
|
||||||
|
ALTER TABLE org_members ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES org_roles(id);
|
||||||
|
|
||||||
|
-- Organization invites
|
||||||
|
CREATE TABLE IF NOT EXISTS org_invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
role_id UUID REFERENCES org_roles(id),
|
||||||
|
role TEXT DEFAULT 'viewer', -- Fallback if no custom role
|
||||||
|
invited_by UUID REFERENCES auth.users(id),
|
||||||
|
token TEXT UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
|
||||||
|
expires_at TIMESTAMPTZ DEFAULT (now() + interval '7 days'),
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE(org_id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_roles_org ON org_roles(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_invites_org ON org_invites(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_invites_token ON org_invites(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_invites_email ON org_invites(email);
|
||||||
|
|
||||||
|
-- RLS for org_roles
|
||||||
|
ALTER TABLE org_roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Org members can view roles" ON org_roles FOR SELECT
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM org_members om
|
||||||
|
WHERE om.org_id = org_roles.org_id AND om.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can manage roles" ON org_roles FOR ALL
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM org_members om
|
||||||
|
WHERE om.org_id = org_roles.org_id
|
||||||
|
AND om.user_id = auth.uid()
|
||||||
|
AND om.role IN ('owner', 'admin')
|
||||||
|
));
|
||||||
|
|
||||||
|
-- RLS for org_invites
|
||||||
|
ALTER TABLE org_invites ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Org members can view invites" ON org_invites FOR SELECT
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM org_members om
|
||||||
|
WHERE om.org_id = org_invites.org_id AND om.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can manage invites" ON org_invites FOR ALL
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM org_members om
|
||||||
|
WHERE om.org_id = org_invites.org_id
|
||||||
|
AND om.user_id = auth.uid()
|
||||||
|
AND om.role IN ('owner', 'admin')
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Anyone can view invite by token (for accepting)
|
||||||
|
CREATE POLICY "Anyone can view invite by token" ON org_invites FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- Function to create default roles for new org
|
||||||
|
CREATE OR REPLACE FUNCTION create_default_org_roles()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Owner role (full permissions)
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(NEW.id, 'Owner', '#ef4444', '["*"]'::jsonb, true, 0);
|
||||||
|
|
||||||
|
-- Admin role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(NEW.id, 'Admin', '#f59e0b', '[
|
||||||
|
"documents.view", "documents.create", "documents.edit", "documents.delete",
|
||||||
|
"kanban.view", "kanban.create", "kanban.edit", "kanban.delete",
|
||||||
|
"calendar.view", "calendar.create", "calendar.edit", "calendar.delete",
|
||||||
|
"members.view", "members.invite", "members.manage",
|
||||||
|
"roles.view", "settings.view", "settings.edit"
|
||||||
|
]'::jsonb, true, 1);
|
||||||
|
|
||||||
|
-- Editor role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, is_default, position) VALUES
|
||||||
|
(NEW.id, 'Editor', '#10b981', '[
|
||||||
|
"documents.view", "documents.create", "documents.edit",
|
||||||
|
"kanban.view", "kanban.create", "kanban.edit",
|
||||||
|
"calendar.view", "calendar.create", "calendar.edit",
|
||||||
|
"members.view"
|
||||||
|
]'::jsonb, true, true, 2);
|
||||||
|
|
||||||
|
-- Commenter role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(NEW.id, 'Commenter', '#6366f1', '[
|
||||||
|
"documents.view",
|
||||||
|
"kanban.view",
|
||||||
|
"calendar.view",
|
||||||
|
"members.view"
|
||||||
|
]'::jsonb, true, 3);
|
||||||
|
|
||||||
|
-- Viewer role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(NEW.id, 'Viewer', '#8b5cf6', '[
|
||||||
|
"documents.view",
|
||||||
|
"kanban.view",
|
||||||
|
"calendar.view",
|
||||||
|
"members.view"
|
||||||
|
]'::jsonb, true, 4);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to create default roles
|
||||||
|
DROP TRIGGER IF EXISTS on_org_created_create_roles ON organizations;
|
||||||
|
CREATE TRIGGER on_org_created_create_roles
|
||||||
|
AFTER INSERT ON organizations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION create_default_org_roles();
|
||||||
|
|
||||||
|
-- Insert default roles for existing organizations
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
org RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR org IN SELECT id FROM organizations LOOP
|
||||||
|
-- Only insert if no roles exist
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM org_roles WHERE org_id = org.id) THEN
|
||||||
|
-- Owner role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(org.id, 'Owner', '#ef4444', '["*"]'::jsonb, true, 0);
|
||||||
|
-- Admin role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(org.id, 'Admin', '#f59e0b', '["documents.view", "documents.create", "documents.edit", "documents.delete", "kanban.view", "kanban.create", "kanban.edit", "kanban.delete", "calendar.view", "calendar.create", "calendar.edit", "calendar.delete", "members.view", "members.invite", "members.manage", "roles.view", "settings.view", "settings.edit"]'::jsonb, true, 1);
|
||||||
|
-- Editor role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, is_default, position) VALUES
|
||||||
|
(org.id, 'Editor', '#10b981', '["documents.view", "documents.create", "documents.edit", "kanban.view", "kanban.create", "kanban.edit", "calendar.view", "calendar.create", "calendar.edit", "members.view"]'::jsonb, true, true, 2);
|
||||||
|
-- Commenter role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(org.id, 'Commenter', '#6366f1', '["documents.view", "kanban.view", "calendar.view", "members.view"]'::jsonb, true, 3);
|
||||||
|
-- Viewer role
|
||||||
|
INSERT INTO org_roles (org_id, name, color, permissions, is_system, position) VALUES
|
||||||
|
(org.id, 'Viewer', '#8b5cf6', '["documents.view", "kanban.view", "calendar.view", "members.view"]'::jsonb, true, 4);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
44
supabase/migrations/006_simplify_google_calendar.sql
Normal file
44
supabase/migrations/006_simplify_google_calendar.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Simplify Google Calendar integration to use public calendars only
|
||||||
|
-- No OAuth needed - just store calendar ID and fetch with API key
|
||||||
|
|
||||||
|
-- Drop the old OAuth-based table
|
||||||
|
DROP TABLE IF EXISTS org_google_calendars CASCADE;
|
||||||
|
DROP TABLE IF EXISTS google_calendar_connections CASCADE;
|
||||||
|
|
||||||
|
-- Create simplified org calendar table
|
||||||
|
CREATE TABLE IF NOT EXISTS org_google_calendars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
calendar_id TEXT NOT NULL, -- The public calendar ID (email format)
|
||||||
|
calendar_name TEXT, -- Display name
|
||||||
|
connected_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS policies
|
||||||
|
ALTER TABLE org_google_calendars ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Members can view org calendar
|
||||||
|
CREATE POLICY "Members can view org calendar" ON org_google_calendars
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM org_members
|
||||||
|
WHERE org_members.org_id = org_google_calendars.org_id
|
||||||
|
AND org_members.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admins/owners can manage org calendar
|
||||||
|
CREATE POLICY "Admins can manage org calendar" ON org_google_calendars
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM org_members
|
||||||
|
WHERE org_members.org_id = org_google_calendars.org_id
|
||||||
|
AND org_members.user_id = auth.uid()
|
||||||
|
AND org_members.role IN ('admin', 'owner')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable realtime
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE org_google_calendars;
|
||||||
Reference in New Issue
Block a user