Mega push vol 5, working on messaging now
This commit is contained in:
369
src/lib/components/settings/SettingsIntegrations.svelte
Normal file
369
src/lib/components/settings/SettingsIntegrations.svelte
Normal file
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { Button, Modal, Card, Input } from "$lib/components/ui";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import {
|
||||
extractCalendarId,
|
||||
getCalendarSubscribeUrl,
|
||||
} from "$lib/api/google-calendar";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface OrgCalendar {
|
||||
id: string;
|
||||
org_id: string;
|
||||
calendar_id: string;
|
||||
calendar_name: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
supabase: SupabaseClient<Database>;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
orgCalendar: OrgCalendar | null;
|
||||
initialShowConnect?: boolean;
|
||||
serviceAccountEmail?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
supabase,
|
||||
orgId,
|
||||
userId,
|
||||
orgCalendar = $bindable(),
|
||||
initialShowConnect = false,
|
||||
serviceAccountEmail = null,
|
||||
}: Props = $props();
|
||||
|
||||
let emailCopied = $state(false);
|
||||
|
||||
async function copyServiceEmail() {
|
||||
if (!serviceAccountEmail) return;
|
||||
await navigator.clipboard.writeText(serviceAccountEmail);
|
||||
emailCopied = true;
|
||||
setTimeout(() => (emailCopied = false), 2000);
|
||||
}
|
||||
|
||||
let showConnectModal = $state(initialShowConnect);
|
||||
let isLoading = $state(false);
|
||||
let calendarUrlInput = $state("");
|
||||
let calendarError = $state<string | null>(null);
|
||||
|
||||
async function handleSaveOrgCalendar() {
|
||||
if (!calendarUrlInput.trim()) return;
|
||||
isLoading = true;
|
||||
calendarError = null;
|
||||
|
||||
const calendarId = extractCalendarId(calendarUrlInput.trim());
|
||||
|
||||
if (!calendarId) {
|
||||
calendarError =
|
||||
"Invalid calendar URL or ID. Please paste a Google Calendar share URL or calendar ID.";
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let calendarName = "Google Calendar";
|
||||
if (calendarId.includes("@group.calendar.google.com")) {
|
||||
calendarName = "Shared Calendar";
|
||||
} else if (calendarId.includes("@gmail.com")) {
|
||||
calendarName = calendarId.split("@")[0] + "'s Calendar";
|
||||
}
|
||||
|
||||
const { data: newCal, error } = await supabase
|
||||
.from("org_google_calendars")
|
||||
.upsert(
|
||||
{
|
||||
org_id: orgId,
|
||||
calendar_id: calendarId,
|
||||
calendar_name: calendarName,
|
||||
connected_by: userId,
|
||||
},
|
||||
{ onConflict: "org_id" },
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
calendarError = "Failed to save calendar.";
|
||||
} else if (newCal) {
|
||||
orgCalendar = newCal as OrgCalendar;
|
||||
calendarUrlInput = "";
|
||||
}
|
||||
|
||||
showConnectModal = false;
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function disconnectOrgCalendar() {
|
||||
if (!confirm("Disconnect Google Calendar?")) return;
|
||||
const { error } = await supabase
|
||||
.from("org_google_calendars")
|
||||
.delete()
|
||||
.eq("org_id", orgId);
|
||||
if (error) {
|
||||
toasts.error(m.toast_error_disconnect_cal());
|
||||
return;
|
||||
}
|
||||
orgCalendar = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-white rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
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="#34A853"
|
||||
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="#FBBC05"
|
||||
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="#EA4335"
|
||||
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>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-light">
|
||||
Google Calendar
|
||||
</h3>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Sync events between your organization and Google
|
||||
Calendar.
|
||||
</p>
|
||||
|
||||
{#if orgCalendar}
|
||||
<div
|
||||
class="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-green-500/10 rounded-lg"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="text-sm font-medium text-green-400"
|
||||
>
|
||||
Connected
|
||||
</p>
|
||||
<p class="text-light font-medium">
|
||||
{orgCalendar.calendar_name ||
|
||||
"Google Calendar"}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-light/50 truncate"
|
||||
title={orgCalendar.calendar_id}
|
||||
>
|
||||
{orgCalendar.calendar_id}
|
||||
</p>
|
||||
<p class="text-xs text-light/40 mt-1">
|
||||
Events sync both ways — create here or
|
||||
in Google Calendar.
|
||||
</p>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(
|
||||
orgCalendar.calendar_id,
|
||||
)}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 mt-2"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
|
||||
/>
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line
|
||||
x1="10"
|
||||
y1="14"
|
||||
x2="21"
|
||||
y2="3"
|
||||
/>
|
||||
</svg>
|
||||
Open in Google Calendar
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={disconnectOrgCalendar}
|
||||
>Disconnect</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !serviceAccountEmail}
|
||||
<div
|
||||
class="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-yellow-400 font-medium">
|
||||
Setup required
|
||||
</p>
|
||||
<p class="text-xs text-light/50 mt-1">
|
||||
A server administrator needs to configure the <code
|
||||
class="bg-light/10 px-1 rounded"
|
||||
>GOOGLE_SERVICE_ACCOUNT_KEY</code
|
||||
> environment variable before calendars can be connected.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4">
|
||||
<Button onclick={() => (showConnectModal = true)}
|
||||
>Connect Google Calendar</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="p-6 opacity-50">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-[#7289da] rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-light">Discord</h3>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Get notifications in your Discord server.
|
||||
</p>
|
||||
<p class="text-xs text-light/40 mt-2">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="p-6 opacity-50">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-[#4A154B] rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52a2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521a2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521a2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523a2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-light">Slack</h3>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Get notifications in your Slack workspace.
|
||||
</p>
|
||||
<p class="text-xs text-light/40 mt-2">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Connect Calendar Modal -->
|
||||
<Modal
|
||||
isOpen={showConnectModal}
|
||||
onClose={() => (showConnectModal = false)}
|
||||
title="Connect Google Calendar"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-light/70">
|
||||
Connect any Google Calendar to your organization. Events you create
|
||||
here will automatically appear in Google Calendar and vice versa.
|
||||
</p>
|
||||
|
||||
<!-- Step 1: Share with service account -->
|
||||
{#if serviceAccountEmail}
|
||||
<div
|
||||
class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg"
|
||||
>
|
||||
<p class="text-blue-400 font-medium text-sm mb-2">
|
||||
Step 1: Share your calendar
|
||||
</p>
|
||||
<p class="text-xs text-light/60 mb-2">
|
||||
In Google Calendar, go to your calendar's settings → "Share
|
||||
with specific people" → add this email with <strong
|
||||
>"Make changes to events"</strong
|
||||
> permission:
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="flex-1 text-xs bg-light/10 px-3 py-2 rounded-lg text-light/80 truncate"
|
||||
title={serviceAccountEmail}
|
||||
>
|
||||
{serviceAccountEmail}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onclick={copyServiceEmail}
|
||||
>
|
||||
{emailCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Paste calendar ID -->
|
||||
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<p class="text-blue-400 font-medium text-sm mb-2">
|
||||
{serviceAccountEmail ? "Step 2" : "Step 1"}: Paste your Calendar
|
||||
ID
|
||||
</p>
|
||||
<p class="text-xs text-light/60 mb-2">
|
||||
In your calendar settings, scroll to "Integrate calendar" and
|
||||
copy the <strong>Calendar ID</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Calendar ID"
|
||||
bind:value={calendarUrlInput}
|
||||
placeholder="e.g. abc123@group.calendar.google.com"
|
||||
/>
|
||||
|
||||
{#if calendarError}
|
||||
<p class="text-red-400 text-sm">{calendarError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showConnectModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
onclick={handleSaveOrgCalendar}
|
||||
loading={isLoading}
|
||||
disabled={!calendarUrlInput.trim()}>Connect</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
398
src/lib/components/settings/SettingsMembers.svelte
Normal file
398
src/lib/components/settings/SettingsMembers.svelte
Normal file
@@ -0,0 +1,398 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
Avatar,
|
||||
} from "$lib/components/ui";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
interface ProfileData {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
role_id: string | null;
|
||||
invited_at: string;
|
||||
profiles: ProfileData | ProfileData[] | null;
|
||||
}
|
||||
|
||||
interface OrgRole {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
permissions: string[];
|
||||
is_default: boolean;
|
||||
is_system: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
role_id: string | null;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
supabase: SupabaseClient<Database>;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
members: Member[];
|
||||
roles: OrgRole[];
|
||||
invites: Invite[];
|
||||
}
|
||||
|
||||
let {
|
||||
supabase,
|
||||
orgId,
|
||||
userId,
|
||||
members = $bindable(),
|
||||
roles,
|
||||
invites = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let showInviteModal = $state(false);
|
||||
let inviteEmail = $state("");
|
||||
let inviteRole = $state("editor");
|
||||
let isSendingInvite = $state(false);
|
||||
let showMemberModal = $state(false);
|
||||
let selectedMember = $state<Member | null>(null);
|
||||
let selectedMemberRole = $state("");
|
||||
|
||||
async function sendInvite() {
|
||||
if (!inviteEmail.trim()) return;
|
||||
isSendingInvite = true;
|
||||
|
||||
const email = inviteEmail.toLowerCase().trim();
|
||||
|
||||
// Delete any existing invite for this email first (handles 409 conflict)
|
||||
await supabase
|
||||
.from("org_invites")
|
||||
.delete()
|
||||
.eq("org_id", orgId)
|
||||
.eq("email", email);
|
||||
|
||||
const { data: invite, error } = await supabase
|
||||
.from("org_invites")
|
||||
.insert({
|
||||
org_id: orgId,
|
||||
email,
|
||||
role: inviteRole,
|
||||
invited_by: userId,
|
||||
expires_at: new Date(
|
||||
Date.now() + INVITE_EXPIRY_MS,
|
||||
).toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && invite) {
|
||||
invites = invites.filter((i) => i.email !== email);
|
||||
invites = [...invites, invite as Invite];
|
||||
inviteEmail = "";
|
||||
showInviteModal = false;
|
||||
} else if (error) {
|
||||
toasts.error(m.toast_error_invite({ error: error.message }));
|
||||
}
|
||||
isSendingInvite = false;
|
||||
}
|
||||
|
||||
async function cancelInvite(inviteId: string) {
|
||||
await supabase.from("org_invites").delete().eq("id", inviteId);
|
||||
invites = invites.filter((i) => i.id !== inviteId);
|
||||
}
|
||||
|
||||
function openMemberModal(member: Member) {
|
||||
selectedMember = member;
|
||||
selectedMemberRole = member.role;
|
||||
showMemberModal = true;
|
||||
}
|
||||
|
||||
async function updateMemberRole() {
|
||||
if (!selectedMember) return;
|
||||
const { error } = await supabase
|
||||
.from("org_members")
|
||||
.update({ role: selectedMemberRole })
|
||||
.eq("id", selectedMember.id);
|
||||
|
||||
if (error) {
|
||||
toasts.error(m.toast_error_update_role());
|
||||
return;
|
||||
}
|
||||
members = members.map((m) =>
|
||||
m.id === selectedMember!.id
|
||||
? { ...m, role: selectedMemberRole }
|
||||
: m,
|
||||
);
|
||||
showMemberModal = false;
|
||||
}
|
||||
|
||||
async function removeMember() {
|
||||
if (!selectedMember) return;
|
||||
const rp = selectedMember.profiles;
|
||||
const prof = Array.isArray(rp) ? rp[0] : rp;
|
||||
if (
|
||||
!confirm(
|
||||
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("org_members")
|
||||
.delete()
|
||||
.eq("id", selectedMember.id);
|
||||
if (error) {
|
||||
toasts.error(m.toast_error_remove_member());
|
||||
return;
|
||||
}
|
||||
members = members.filter((m) => m.id !== selectedMember!.id);
|
||||
showMemberModal = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-light">
|
||||
{m.settings_members_title({
|
||||
count: String(members.length),
|
||||
})}
|
||||
</h2>
|
||||
<Button onclick={() => (showInviteModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="9"
|
||||
cy="7"
|
||||
r="4"
|
||||
/><line x1="19" y1="8" x2="19" y2="14" /><line
|
||||
x1="22"
|
||||
y1="11"
|
||||
x2="16"
|
||||
y2="11"
|
||||
/>
|
||||
</svg>
|
||||
{m.settings_members_invite()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
{#if invites.length > 0}
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-medium text-light/70 mb-3">
|
||||
{m.settings_members_pending()}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each invites as invite}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p class="text-light">{invite.email}</p>
|
||||
<p class="text-xs text-light/40">
|
||||
Invited as {invite.role} • Expires {new Date(
|
||||
invite.expires_at,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/invite/${invite.token}`,
|
||||
)}
|
||||
>{m.settings_members_copy_link()}</Button
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => cancelInvite(invite.id)}
|
||||
>Cancel</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Members List -->
|
||||
<Card>
|
||||
<div class="divide-y divide-light/10">
|
||||
{#each members as member}
|
||||
{@const rawProfile = member.profiles}
|
||||
{@const profile = Array.isArray(rawProfile)
|
||||
? rawProfile[0]
|
||||
: rawProfile}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
{(profile?.full_name ||
|
||||
profile?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-light font-medium">
|
||||
{profile?.full_name ||
|
||||
profile?.email ||
|
||||
"Unknown User"}
|
||||
</p>
|
||||
<p class="text-sm text-light/50">
|
||||
{profile?.email || "No email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full capitalize"
|
||||
style="background-color: {roles.find(
|
||||
(r) => r.name.toLowerCase() === member.role,
|
||||
)?.color ?? '#6366f1'}20; color: {roles.find(
|
||||
(r) => r.name.toLowerCase() === member.role,
|
||||
)?.color ?? '#6366f1'}">{member.role}</span
|
||||
>
|
||||
{#if member.user_id !== userId && member.role !== "owner"}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() => openMemberModal(member)}
|
||||
>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Invite Member Modal -->
|
||||
<Modal
|
||||
isOpen={showInviteModal}
|
||||
onClose={() => (showInviteModal = false)}
|
||||
title="Invite Member"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
label="Email address"
|
||||
bind:value={inviteEmail}
|
||||
placeholder="colleague@example.com"
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
bind:value={inviteRole}
|
||||
placeholder=""
|
||||
options={[
|
||||
{ value: "viewer", label: "Viewer - Can view content" },
|
||||
{
|
||||
value: "commenter",
|
||||
label: "Commenter - Can view and comment",
|
||||
},
|
||||
{
|
||||
value: "editor",
|
||||
label: "Editor - Can create and edit content",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin - Can manage members and settings",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
onclick={sendInvite}
|
||||
loading={isSendingInvite}
|
||||
disabled={!inviteEmail.trim()}>Send Invite</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Member Modal -->
|
||||
<Modal
|
||||
isOpen={showMemberModal}
|
||||
onClose={() => (showMemberModal = false)}
|
||||
title="Edit Member"
|
||||
>
|
||||
{#if selectedMember}
|
||||
{@const rawP = selectedMember.profiles}
|
||||
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
{(memberProfile?.full_name ||
|
||||
memberProfile?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-light font-medium">
|
||||
{memberProfile?.full_name || "No name"}
|
||||
</p>
|
||||
<p class="text-sm text-light/50">
|
||||
{memberProfile?.email || "No email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
label="Role"
|
||||
bind:value={selectedMemberRole}
|
||||
placeholder=""
|
||||
options={[
|
||||
{ value: "viewer", label: "Viewer" },
|
||||
{ value: "commenter", label: "Commenter" },
|
||||
{ value: "editor", label: "Editor" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
]}
|
||||
/>
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onclick={removeMember}
|
||||
>Remove from Org</Button
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showMemberModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button onclick={updateMemberRole}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
350
src/lib/components/settings/SettingsRoles.svelte
Normal file
350
src/lib/components/settings/SettingsRoles.svelte
Normal file
@@ -0,0 +1,350 @@
|
||||
<script lang="ts">
|
||||
import { Button, Modal, Card, Input } from "$lib/components/ui";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface OrgRole {
|
||||
id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
permissions: string[];
|
||||
is_default: boolean;
|
||||
is_system: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
supabase: SupabaseClient<Database>;
|
||||
orgId: string;
|
||||
roles: OrgRole[];
|
||||
}
|
||||
|
||||
let { supabase, orgId, roles = $bindable() }: Props = $props();
|
||||
|
||||
let showRoleModal = $state(false);
|
||||
let editingRole = $state<OrgRole | null>(null);
|
||||
let newRoleName = $state("");
|
||||
let newRoleColor = $state("#6366f1");
|
||||
let newRolePermissions = $state<string[]>([]);
|
||||
let isSavingRole = $state(false);
|
||||
|
||||
const permissionGroups = [
|
||||
{
|
||||
name: "Documents",
|
||||
permissions: [
|
||||
"documents.view",
|
||||
"documents.create",
|
||||
"documents.edit",
|
||||
"documents.delete",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Kanban",
|
||||
permissions: [
|
||||
"kanban.view",
|
||||
"kanban.create",
|
||||
"kanban.edit",
|
||||
"kanban.delete",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Calendar",
|
||||
permissions: [
|
||||
"calendar.view",
|
||||
"calendar.create",
|
||||
"calendar.edit",
|
||||
"calendar.delete",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Members",
|
||||
permissions: [
|
||||
"members.view",
|
||||
"members.invite",
|
||||
"members.manage",
|
||||
"members.remove",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Roles",
|
||||
permissions: [
|
||||
"roles.view",
|
||||
"roles.create",
|
||||
"roles.edit",
|
||||
"roles.delete",
|
||||
],
|
||||
},
|
||||
{ name: "Settings", permissions: ["settings.view", "settings.edit"] },
|
||||
];
|
||||
|
||||
const roleColors = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#f59e0b", label: "Amber" },
|
||||
{ value: "#10b981", label: "Emerald" },
|
||||
{ value: "#3b82f6", label: "Blue" },
|
||||
{ value: "#6366f1", label: "Indigo" },
|
||||
{ value: "#8b5cf6", label: "Violet" },
|
||||
{ value: "#ec4899", label: "Pink" },
|
||||
{ value: "#6b7280", label: "Gray" },
|
||||
];
|
||||
|
||||
function openRoleModal(role?: OrgRole) {
|
||||
if (role) {
|
||||
editingRole = role;
|
||||
newRoleName = role.name;
|
||||
newRoleColor = role.color;
|
||||
newRolePermissions = [...role.permissions];
|
||||
} else {
|
||||
editingRole = null;
|
||||
newRoleName = "";
|
||||
newRoleColor = "#6366f1";
|
||||
newRolePermissions = [
|
||||
"documents.view",
|
||||
"kanban.view",
|
||||
"calendar.view",
|
||||
"members.view",
|
||||
];
|
||||
}
|
||||
showRoleModal = true;
|
||||
}
|
||||
|
||||
async function saveRole() {
|
||||
if (!newRoleName.trim()) return;
|
||||
isSavingRole = true;
|
||||
|
||||
if (editingRole) {
|
||||
const { error } = await supabase
|
||||
.from("org_roles")
|
||||
.update({
|
||||
name: newRoleName,
|
||||
color: newRoleColor,
|
||||
permissions: newRolePermissions,
|
||||
})
|
||||
.eq("id", editingRole.id);
|
||||
|
||||
if (!error) {
|
||||
roles = roles.map((r) =>
|
||||
r.id === editingRole!.id
|
||||
? {
|
||||
...r,
|
||||
name: newRoleName,
|
||||
color: newRoleColor,
|
||||
permissions: newRolePermissions,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const { data: role, error } = await supabase
|
||||
.from("org_roles")
|
||||
.insert({
|
||||
org_id: orgId,
|
||||
name: newRoleName,
|
||||
color: newRoleColor,
|
||||
permissions: newRolePermissions,
|
||||
position: roles.length,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && role) {
|
||||
roles = [...roles, role as OrgRole];
|
||||
}
|
||||
}
|
||||
|
||||
showRoleModal = false;
|
||||
isSavingRole = false;
|
||||
}
|
||||
|
||||
async function deleteRole(role: OrgRole) {
|
||||
if (role.is_system) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Delete role "${role.name}"? Members with this role will need to be reassigned.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("org_roles")
|
||||
.delete()
|
||||
.eq("id", role.id);
|
||||
if (error) {
|
||||
toasts.error(m.toast_error_delete_role());
|
||||
return;
|
||||
}
|
||||
roles = roles.filter((r) => r.id !== role.id);
|
||||
}
|
||||
|
||||
function togglePermission(perm: string) {
|
||||
if (newRolePermissions.includes(perm)) {
|
||||
newRolePermissions = newRolePermissions.filter((p) => p !== perm);
|
||||
} else {
|
||||
newRolePermissions = [...newRolePermissions, perm];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-light">Roles</h2>
|
||||
<p class="text-sm text-light/50">
|
||||
Create custom roles with specific permissions.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => openRoleModal()} icon="add">
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
{#each roles as role}
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {role.color}"
|
||||
></div>
|
||||
<span class="font-medium text-light"
|
||||
>{role.name}</span
|
||||
>
|
||||
{#if role.is_system}
|
||||
<span
|
||||
class="text-xs text-light/40 bg-light/10 px-2 py-0.5 rounded"
|
||||
>System</span
|
||||
>
|
||||
{/if}
|
||||
{#if role.is_default}
|
||||
<span
|
||||
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded"
|
||||
>Default</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !role.is_system || role.name !== "Owner"}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() => openRoleModal(role)}
|
||||
>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
{#if !role.is_system}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => deleteRole(role)}
|
||||
>Delete</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if role.permissions.includes("*")}
|
||||
<span
|
||||
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
|
||||
>All Permissions</span
|
||||
>
|
||||
{:else}
|
||||
{#each role.permissions.slice(0, 6) as perm}
|
||||
<span
|
||||
class="text-xs bg-light/10 text-light/50 px-2 py-1 rounded"
|
||||
>{perm}</span
|
||||
>
|
||||
{/each}
|
||||
{#if role.permissions.length > 6}
|
||||
<span class="text-xs text-light/40"
|
||||
>+{role.permissions.length - 6} more</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit/Create Role Modal -->
|
||||
<Modal
|
||||
isOpen={showRoleModal}
|
||||
onClose={() => (showRoleModal = false)}
|
||||
title={editingRole ? "Edit Role" : "Create Role"}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={newRoleName}
|
||||
placeholder="e.g., Moderator"
|
||||
disabled={editingRole?.is_system}
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-light mb-2"
|
||||
>Color</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
{#each roleColors as color}
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 rounded-full transition-transform {newRoleColor ===
|
||||
color.value
|
||||
? 'ring-2 ring-white scale-110'
|
||||
: ''}"
|
||||
style="background-color: {color.value}"
|
||||
onclick={() => (newRoleColor = color.value)}
|
||||
title={color.label}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-light mb-2"
|
||||
>Permissions</label
|
||||
>
|
||||
<div class="space-y-3 max-h-64 overflow-y-auto">
|
||||
{#each permissionGroups as group}
|
||||
<div class="p-3 bg-light/5 rounded-lg">
|
||||
<p class="text-sm font-medium text-light mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each group.permissions as perm}
|
||||
<label
|
||||
class="flex items-center gap-2 text-sm text-light/70 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRolePermissions.includes(
|
||||
perm,
|
||||
)}
|
||||
onchange={() => togglePermission(perm)}
|
||||
class="rounded"
|
||||
/>
|
||||
{perm.split(".")[1]}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
onclick={saveRole}
|
||||
loading={isSavingRole}
|
||||
disabled={!newRoleName.trim()}
|
||||
>{editingRole ? "Save" : "Create"}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -1 +1,4 @@
|
||||
export { default as SettingsGeneral } from './SettingsGeneral.svelte';
|
||||
export { default as SettingsMembers } from './SettingsMembers.svelte';
|
||||
export { default as SettingsRoles } from './SettingsRoles.svelte';
|
||||
export { default as SettingsIntegrations } from './SettingsIntegrations.svelte';
|
||||
|
||||
Reference in New Issue
Block a user