337 lines
8.4 KiB
Svelte
337 lines
8.4 KiB
Svelte
<script lang="ts">
|
|
import { Button, Modal, 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-4 max-w-2xl">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="font-heading text-body text-white">Roles</h2>
|
|
<p class="text-body-sm text-light/40 mt-0.5">
|
|
Create custom roles with specific permissions.
|
|
</p>
|
|
</div>
|
|
<Button size="sm" onclick={() => openRoleModal()} icon="add">
|
|
Create Role
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
{#each roles as role}
|
|
<div class="bg-dark/30 border border-light/5 rounded-xl px-4 py-3 hover:border-light/10 transition-colors">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<div
|
|
class="w-2.5 h-2.5 rounded-full shrink-0"
|
|
style="background-color: {role.color}"
|
|
></div>
|
|
<span class="text-body-sm font-medium text-white">{role.name}</span>
|
|
{#if role.is_system}
|
|
<span class="text-[10px] text-light/30 bg-light/5 px-1.5 py-0.5 rounded-md">System</span>
|
|
{/if}
|
|
{#if role.is_default}
|
|
<span class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded-md">Default</span>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
{#if !role.is_system || role.name !== "Owner"}
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
|
onclick={() => openRoleModal(role)}
|
|
title="Edit"
|
|
>
|
|
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
|
|
</button>
|
|
{/if}
|
|
{#if !role.is_system}
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
|
|
onclick={() => deleteRole(role)}
|
|
title="Delete"
|
|
>
|
|
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">delete</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
{#if role.permissions.includes("*")}
|
|
<span class="text-[10px] bg-light/5 text-light/50 px-1.5 py-0.5 rounded-md">All Permissions</span>
|
|
{:else}
|
|
{#each role.permissions.slice(0, 6) as perm}
|
|
<span class="text-[10px] bg-light/5 text-light/40 px-1.5 py-0.5 rounded-md">{perm}</span>
|
|
{/each}
|
|
{#if role.permissions.length > 6}
|
|
<span class="text-[10px] text-light/30">+{role.permissions.length - 6} more</span>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/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>
|