add admin permission

This commit is contained in:
thanawat saiyota 2026-03-26 14:57:11 +07:00
parent 3388eca2fe
commit 7ea73543b7
19 changed files with 1567 additions and 5 deletions

View file

@ -0,0 +1,11 @@
import { redirect, type Cookies } from '@sveltejs/kit';
export async function load({ cookies, url }: { cookies: Cookies; url: URL }) {
// Check if user is logged in
if (!cookies.get('logged_in')) {
redirect(303, `/login?redirectTo=${url.pathname}`);
}
// Admin permission check will be done client-side
// because we need to access Firebase Firestore
}

View file

@ -0,0 +1,103 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { auth, authInitialized } from '$lib/core/stores/auth';
import { isUserAdmin } from '$lib/core/admin/adminService';
import * as Tabs from '$lib/components/ui/tabs/index';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { Users, Shield, Settings } from '@lucide/svelte';
let { children } = $props();
let loading = $state(true);
let isAdmin = $state(false);
let checkedAdmin = $state(false);
const currentPath = $derived(page.url.pathname);
const activeTab = $derived(
currentPath.includes('/admin/roles')
? 'roles'
: currentPath.includes('/admin/settings')
? 'settings'
: 'users'
);
// Wait for Firebase to initialize and check admin status
$effect(() => {
const initialized = $authInitialized;
const currentUser = $auth;
if (!initialized) {
// Still waiting for Firebase to check session
return;
}
if (!currentUser) {
// Firebase initialized but no user
goto('/login');
return;
}
// User exists, check admin status (only once)
if (!checkedAdmin) {
checkedAdmin = true;
isUserAdmin(currentUser.uid)
.then((result) => {
isAdmin = result;
if (!result) {
goto('/dashboard');
} else {
loading = false;
}
})
.catch((error) => {
console.error('Error checking admin status:', error);
goto('/dashboard');
});
}
});
function handleTabChange(value: string) {
if (value === 'users') goto('/admin/users');
else if (value === 'roles') goto('/admin/roles');
else if (value === 'settings') goto('/admin/settings');
}
</script>
{#if loading}
<div class="flex h-full w-full items-center justify-center">
<Spinner class="size-12" />
</div>
{:else if isAdmin}
<div class="flex h-full flex-col overflow-hidden p-4">
<div class="mb-4">
<h1 class="text-2xl font-bold">Admin Panel</h1>
<p class="text-muted-foreground text-sm">Manage users, roles, and system settings</p>
</div>
<Tabs.Root value={activeTab} onValueChange={handleTabChange} class="flex flex-1 flex-col overflow-hidden">
<Tabs.List class="grid w-full max-w-md grid-cols-3">
<Tabs.Trigger value="users" class="flex items-center gap-2">
<Users class="h-4 w-4" />
Users
</Tabs.Trigger>
<Tabs.Trigger value="roles" class="flex items-center gap-2">
<Shield class="h-4 w-4" />
Roles
</Tabs.Trigger>
<Tabs.Trigger value="settings" class="flex items-center gap-2">
<Settings class="h-4 w-4" />
Settings
</Tabs.Trigger>
</Tabs.List>
<div class="mt-4 flex-1 overflow-auto">
{@render children()}
</div>
</Tabs.Root>
</div>
{:else}
<div class="flex h-full w-full items-center justify-center">
<p class="text-muted-foreground">Access denied. Admin privileges required.</p>
</div>
{/if}

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
goto('/admin/users', { replaceState: true });
});
</script>
<div class="flex h-full w-full items-center justify-center">
<p class="text-muted-foreground">Redirecting...</p>
</div>

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getRoleDefinitions, buildAvailablePermissions } from '$lib/core/admin/adminService';
import { rolesConfig, rolesLoading, availablePermissions } from '$lib/core/admin/adminStore';
import RoleCard from './role-card.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { RefreshCw } from '@lucide/svelte';
import Button from '$lib/components/ui/button/button.svelte';
import type { UserRole } from '$lib/core/admin/adminTypes';
const roleOrder: UserRole[] = ['guest', 'viewer', 'admin'];
const roleDescriptions: Record<UserRole, string> = {
guest: 'Default role for new users. No access to any features.',
viewer: 'Can view data but cannot make changes.',
admin: 'Full access to all features and admin panel.'
};
async function loadRoles() {
rolesLoading.set(true);
try {
const [roles, perms] = await Promise.all([
getRoleDefinitions(),
buildAvailablePermissions()
]);
rolesConfig.set(roles);
availablePermissions.set(perms);
} catch (error) {
console.error('Error loading roles:', error);
} finally {
rolesLoading.set(false);
}
}
onMount(() => {
loadRoles();
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Roles</h2>
<p class="text-muted-foreground text-sm">View and manage role definitions</p>
</div>
<Button variant="outline" size="sm" onclick={loadRoles} disabled={$rolesLoading}>
<RefreshCw class={`mr-2 h-4 w-4 ${$rolesLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{#if $rolesLoading}
<div class="flex items-center justify-center py-12">
<Spinner class="h-8 w-8" />
</div>
{:else if $rolesConfig}
<div class="grid gap-4 md:grid-cols-3">
{#each roleOrder as role}
<RoleCard
{role}
description={roleDescriptions[role]}
permissions={$rolesConfig.definitions[role]?.permissions || []}
availablePermissions={$availablePermissions}
onUpdate={loadRoles}
/>
{/each}
</div>
{:else}
<div class="rounded-md border border-yellow-200 bg-yellow-50 p-4">
<p class="text-sm text-yellow-600">No role definitions found.</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,195 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index';
import * as Dialog from '$lib/components/ui/dialog/index';
import Button from '$lib/components/ui/button/button.svelte';
import { Badge } from '$lib/components/ui/badge/index';
import { Checkbox } from '$lib/components/ui/checkbox/index';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { Pencil, Shield, Eye, User } from '@lucide/svelte';
import { updateRoleDefinition } from '$lib/core/admin/adminService';
import { REGION_LABELS, TOOL_LABELS, type UserRole } from '$lib/core/admin/adminTypes';
import { addNotification } from '$lib/core/stores/noti';
let {
role,
description,
permissions,
availablePermissions,
onUpdate
}: {
role: UserRole;
description: string;
permissions: string[];
availablePermissions: string[];
onUpdate: () => void;
} = $props();
let dialogOpen = $state(false);
let selectedPermissions = $state<string[]>([]);
let saving = $state(false);
const roleIcons: Record<UserRole, typeof Shield> = {
admin: Shield,
viewer: Eye,
guest: User
};
const roleColors: Record<UserRole, string> = {
admin: 'bg-red-100 text-red-800',
viewer: 'bg-blue-100 text-blue-800',
guest: 'bg-gray-100 text-gray-800'
};
// Group permissions by category
function groupPermissions(perms: string[]) {
const groups: Record<string, string[]> = {
'Document Read': [],
'Document Write': [],
Tools: [],
Other: []
};
for (const perm of perms) {
if (perm === 'no_permission') continue;
if (perm.startsWith('document.read.')) {
groups['Document Read'].push(perm);
} else if (perm.startsWith('document.write.')) {
groups['Document Write'].push(perm);
} else if (perm.startsWith('tools.')) {
groups['Tools'].push(perm);
} else {
groups['Other'].push(perm);
}
}
return groups;
}
function getPermissionLabel(perm: string): string {
if (perm.startsWith('document.read.')) {
const region = perm.replace('document.read.', '');
return REGION_LABELS[region] || region;
}
if (perm.startsWith('document.write.')) {
const region = perm.replace('document.write.', '');
return REGION_LABELS[region] || region;
}
if (perm.startsWith('tools.core.')) {
const tool = perm.replace('tools.core.', '');
return TOOL_LABELS[tool] || tool;
}
return perm;
}
function togglePermission(perm: string) {
if (selectedPermissions.includes(perm)) {
selectedPermissions = selectedPermissions.filter((p) => p !== perm);
} else {
selectedPermissions = [...selectedPermissions, perm];
}
}
function openDialog() {
selectedPermissions = permissions.filter((p) => p !== 'no_permission');
dialogOpen = true;
}
async function handleSave() {
saving = true;
try {
const finalPermissions =
selectedPermissions.length === 0 ? ['no_permission'] : selectedPermissions;
await updateRoleDefinition(role, finalPermissions);
addNotification(`SUCCESS:Role "${role}" updated successfully`);
dialogOpen = false;
onUpdate();
} catch (error) {
console.error('Error updating role:', error);
addNotification('ERROR:Failed to update role');
} finally {
saving = false;
}
}
const Icon = $derived(roleIcons[role]);
const displayPermissions = $derived(permissions.filter((p) => p !== 'no_permission'));
const groupedAvailable = $derived(groupPermissions(availablePermissions));
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class={`rounded-lg p-2 ${roleColors[role]}`}>
<Icon class="h-5 w-5" />
</div>
<Card.Title class="capitalize">{role}</Card.Title>
</div>
<Button variant="ghost" size="sm" onclick={openDialog}>
<Pencil class="h-4 w-4" />
</Button>
</div>
<Card.Description>{description}</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-2">
<p class="text-muted-foreground text-sm font-medium">
{displayPermissions.length} permission(s)
</p>
<div class="flex flex-wrap gap-1">
{#if displayPermissions.length === 0}
<Badge variant="outline">No permissions</Badge>
{:else}
{#each displayPermissions.slice(0, 5) as perm}
<Badge variant="secondary" class="text-xs">{getPermissionLabel(perm)}</Badge>
{/each}
{#if displayPermissions.length > 5}
<Badge variant="outline" class="text-xs">+{displayPermissions.length - 5} more</Badge>
{/if}
{/if}
</div>
</div>
</Card.Content>
</Card.Root>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title>Edit {role} Role</Dialog.Title>
<Dialog.Description>Select permissions for this role</Dialog.Description>
</Dialog.Header>
<div class="max-h-[400px] space-y-4 overflow-y-auto py-4">
{#each Object.entries(groupedAvailable) as [group, perms]}
{#if perms.length > 0}
<div class="space-y-2">
<p class="text-muted-foreground text-sm font-medium">{group}</p>
<div class="grid grid-cols-2 gap-2">
{#each perms as perm}
<label class="flex cursor-pointer items-center gap-2">
<Checkbox
checked={selectedPermissions.includes(perm)}
onCheckedChange={() => togglePermission(perm)}
/>
<span class="text-sm">{getPermissionLabel(perm)}</span>
</label>
{/each}
</div>
</div>
{/if}
{/each}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (dialogOpen = false)} disabled={saving}>
Cancel
</Button>
<Button onclick={handleSave} disabled={saving}>
{#if saving}
<Spinner class="mr-2 h-4 w-4" />
{/if}
Save Changes
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -0,0 +1,142 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
getDocumentPermissions,
getToolsPermissions,
getAllowedDomains
} from '$lib/core/admin/adminService';
import * as Card from '$lib/components/ui/card/index';
import { Badge } from '$lib/components/ui/badge/index';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { RefreshCw, Globe, Wrench, Mail } from '@lucide/svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { REGION_LABELS, TOOL_LABELS } from '$lib/core/admin/adminTypes';
let loading = $state(true);
let documentPerms = $state<{ read: string[]; write: string[] } | null>(null);
let toolsPerms = $state<{ core: string[] } | null>(null);
let allowedDomains = $state<string[]>([]);
async function loadSettings() {
loading = true;
try {
const [docs, tools, domains] = await Promise.all([
getDocumentPermissions(),
getToolsPermissions(),
getAllowedDomains()
]);
documentPerms = docs;
toolsPerms = tools;
allowedDomains = domains;
} catch (error) {
console.error('Error loading settings:', error);
} finally {
loading = false;
}
}
onMount(() => {
loadSettings();
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Settings</h2>
<p class="text-muted-foreground text-sm">View system configuration (read-only)</p>
</div>
<Button variant="outline" size="sm" onclick={loadSettings} disabled={loading}>
<RefreshCw class={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<Spinner class="h-8 w-8" />
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2">
<!-- Document Regions -->
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Globe class="h-5 w-5 text-blue-500" />
<Card.Title>Document Regions</Card.Title>
</div>
<Card.Description>Available regions for document access</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#if documentPerms}
<div class="space-y-2">
<p class="text-muted-foreground text-sm font-medium">Read Access</p>
<div class="flex flex-wrap gap-1">
{#each documentPerms.read as region}
<Badge variant="secondary">{REGION_LABELS[region] || region}</Badge>
{/each}
</div>
</div>
<div class="space-y-2">
<p class="text-muted-foreground text-sm font-medium">Write Access</p>
<div class="flex flex-wrap gap-1">
{#each documentPerms.write as region}
<Badge variant="secondary">{REGION_LABELS[region] || region}</Badge>
{/each}
</div>
</div>
{:else}
<p class="text-muted-foreground text-sm">No document permissions configured</p>
{/if}
</Card.Content>
</Card.Root>
<!-- Tools Permissions -->
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Wrench class="h-5 w-5 text-orange-500" />
<Card.Title>Tools Permissions</Card.Title>
</div>
<Card.Description>Available tool permissions</Card.Description>
</Card.Header>
<Card.Content>
{#if toolsPerms}
<div class="space-y-2">
<p class="text-muted-foreground text-sm font-medium">Core Tools</p>
<div class="flex flex-wrap gap-1">
{#each toolsPerms.core as tool}
<Badge variant="secondary">{TOOL_LABELS[tool] || tool}</Badge>
{/each}
</div>
</div>
{:else}
<p class="text-muted-foreground text-sm">No tool permissions configured</p>
{/if}
</Card.Content>
</Card.Root>
<!-- Whitelist -->
<Card.Root class="md:col-span-2">
<Card.Header>
<div class="flex items-center gap-2">
<Mail class="h-5 w-5 text-green-500" />
<Card.Title>Email Domain Whitelist</Card.Title>
</div>
<Card.Description>Allowed email domains for login</Card.Description>
</Card.Header>
<Card.Content>
{#if allowedDomains.length > 0}
<div class="flex flex-wrap gap-2">
{#each allowedDomains as domain}
<Badge variant="outline" class="text-sm">@{domain}</Badge>
{/each}
</div>
{:else}
<p class="text-muted-foreground text-sm">No domains configured</p>
{/if}
</Card.Content>
</Card.Root>
</div>
{/if}
</div>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getAllUsers } from '$lib/core/admin/adminService';
import { adminUsers, usersLoading, adminError } from '$lib/core/admin/adminStore';
import { columns } from './columns';
import DataTable from './data-table.svelte';
import UserEditSheet from './user-edit-sheet.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { RefreshCw } from '@lucide/svelte';
import Button from '$lib/components/ui/button/button.svelte';
async function loadUsers() {
usersLoading.set(true);
adminError.set(null);
try {
const users = await getAllUsers();
adminUsers.set(users);
} catch (error) {
console.error('Error loading users:', error);
adminError.set('Failed to load users');
} finally {
usersLoading.set(false);
}
}
onMount(() => {
loadUsers();
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">Users</h2>
<p class="text-muted-foreground text-sm">Manage user roles and permissions</p>
</div>
<Button variant="outline" size="sm" onclick={loadUsers} disabled={$usersLoading}>
<RefreshCw class={`mr-2 h-4 w-4 ${$usersLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{#if $usersLoading}
<div class="flex items-center justify-center py-12">
<Spinner class="h-8 w-8" />
</div>
{:else if $adminError}
<div class="rounded-md border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-600">{$adminError}</p>
</div>
{:else}
<DataTable data={$adminUsers} {columns} />
{/if}
</div>
<UserEditSheet />

View file

@ -0,0 +1,57 @@
import { renderComponent } from '$lib/components/ui/data-table';
import type { ColumnDef } from '@tanstack/table-core';
import type { AdminUser } from '$lib/core/admin/adminTypes';
import DataTableRoleBadge from './data-table-role-badge.svelte';
import DataTableActions from './data-table-actions.svelte';
import DataTableAvatar from './data-table-avatar.svelte';
export const columns: ColumnDef<AdminUser>[] = [
{
id: 'avatar',
header: '',
cell: ({ row }) => {
return renderComponent(DataTableAvatar, {
photoURL: row.original.photoURL,
displayName: row.original.displayName
});
},
enableGlobalFilter: false
},
{
accessorKey: 'displayName',
header: 'Name',
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
accessorKey: 'email',
header: 'Email',
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => {
return renderComponent(DataTableRoleBadge, { role: row.original.role });
},
enableGlobalFilter: true,
filterFn: 'includesString'
},
{
id: 'permissionCount',
header: 'Permissions',
cell: ({ row }) => {
const count = row.original.permissions?.length || 0;
return `${count} permissions`;
},
enableGlobalFilter: false
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
return renderComponent(DataTableActions, { user: row.original });
}
}
];

View file

@ -0,0 +1,18 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { Pencil } from '@lucide/svelte';
import type { AdminUser } from '$lib/core/admin/adminTypes';
import { selectedUser, editSheetOpen } from '$lib/core/admin/adminStore';
let { user }: { user: AdminUser } = $props();
function handleEdit() {
selectedUser.set(user);
editSheetOpen.set(true);
}
</script>
<Button variant="ghost" size="sm" onclick={handleEdit}>
<Pencil class="h-4 w-4" />
<span class="sr-only">Edit</span>
</Button>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { User } from '@lucide/svelte';
let { photoURL, displayName }: { photoURL: string; displayName: string } = $props();
</script>
<div class="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-gray-200">
{#if photoURL}
<img src={photoURL} alt={displayName} class="h-full w-full object-cover" />
{:else}
<User class="h-5 w-5 text-gray-500" />
{/if}
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index';
import type { UserRole } from '$lib/core/admin/adminTypes';
let { role }: { role: UserRole } = $props();
const roleVariants: Record<UserRole, 'default' | 'secondary' | 'destructive' | 'outline'> = {
admin: 'destructive',
viewer: 'secondary',
guest: 'outline'
};
const roleLabels: Record<UserRole, string> = {
admin: 'Admin',
viewer: 'Viewer',
guest: 'Guest'
};
</script>
<Badge variant={roleVariants[role] || 'outline'}>
{roleLabels[role] || role}
</Badge>

View file

@ -0,0 +1,160 @@
<script lang="ts">
import {
type ColumnDef,
type ColumnFiltersState,
type FilterFn,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type GlobalFilterTableState,
type PaginationState,
type SortingState
} from '@tanstack/table-core';
import { rankItem } from '@tanstack/match-sorter-utils';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index';
import * as Table from '$lib/components/ui/table/index';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import { SearchIcon } from '@lucide/svelte/icons';
import type { AdminUser } from '$lib/core/admin/adminTypes';
let { data, columns }: { data: AdminUser[]; columns: ColumnDef<AdminUser>[] } = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });
let sorting = $state<SortingState>([]);
let columnFilter = $state<ColumnFiltersState>([]);
let globalFilter = $state<GlobalFilterTableState>();
const fuzzyFilter: FilterFn<AdminUser> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value);
addMeta({ itemRank });
return itemRank.passed;
};
const table = createSvelteTable({
get data() {
return data;
},
columns,
state: {
get pagination() {
return pagination;
},
get sorting() {
return sorting;
},
get globalFilter() {
return globalFilter;
}
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
filterFns: {
fuzzy: fuzzyFilter
},
globalFilterFn: fuzzyFilter,
onSortingChange: (updater) => {
if (typeof updater === 'function') {
sorting = updater(sorting);
} else {
sorting = updater;
}
},
onPaginationChange: (updater) => {
if (typeof updater === 'function') {
pagination = updater(pagination);
} else {
pagination = updater;
}
},
onColumnFiltersChange: (updater) => {
if (typeof updater === 'function') {
columnFilter = updater(columnFilter);
} else {
columnFilter = updater;
}
},
onGlobalFilterChange: (updater) => {
if (typeof updater === 'function') {
globalFilter = updater(globalFilter);
} else {
globalFilter = updater;
}
}
});
</script>
<div>
<div class="flex items-center gap-2 py-4">
<SearchIcon class="h-5 w-5 text-gray-500" />
<Input
type="text"
placeholder="Search by name or email..."
class="max-w-sm"
onchange={(e) => {
table.setGlobalFilter(e.currentTarget.value);
}}
oninput={(e) => {
table.setGlobalFilter(e.currentTarget.value);
}}
/>
</div>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head colspan={header.colSpan}>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && 'selected'}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">No users found.</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mx-4 flex items-center justify-between py-4">
<span class="text-muted-foreground text-sm">
{table.getFilteredRowModel().rows.length} user(s) total
</span>
<div class="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}>Previous</Button
>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}>Next</Button
>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,309 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index';
import Button from '$lib/components/ui/button/button.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index';
import { Checkbox } from '$lib/components/ui/checkbox/index';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { User } from '@lucide/svelte';
import { selectedUser, editSheetOpen, adminUsers } from '$lib/core/admin/adminStore';
import { updateUser, buildAvailablePermissions } from '$lib/core/admin/adminService';
import { REGION_LABELS, TOOL_LABELS, type UserRole } from '$lib/core/admin/adminTypes';
import { addNotification } from '$lib/core/stores/noti';
import { onMount } from 'svelte';
let availablePermissions = $state<string[]>([]);
let selectedRole = $state<UserRole>('guest');
let selectedPermissions = $state<string[]>([]);
let saving = $state(false);
let loadingPermissions = $state(true);
const roles: { value: UserRole; label: string }[] = [
{ value: 'guest', label: 'Guest' },
{ value: 'viewer', label: 'Viewer' },
{ value: 'admin', label: 'Admin' }
];
const groupedPermissions = $derived(() => {
const groups: Record<string, string[]> = {
'Document Read': [],
'Document Write': [],
Tools: [],
Other: []
};
for (const perm of availablePermissions) {
if (perm.startsWith('document.read.')) {
groups['Document Read'].push(perm);
} else if (perm.startsWith('document.write.')) {
groups['Document Write'].push(perm);
} else if (perm.startsWith('tools.')) {
groups['Tools'].push(perm);
} else if (perm !== 'no_permission') {
groups['Other'].push(perm);
}
}
return groups;
});
// Check if no_permission is selected for a group
function isNoPermissionSelected(group: string): boolean {
if (group === 'Document Read') {
return selectedPermissions.includes('document.read.no_permission');
}
if (group === 'Document Write') {
return selectedPermissions.includes('document.write.no_permission');
}
return false;
}
function getPermissionLabel(perm: string): string {
if (perm === 'document.read.no_permission' || perm === 'document.write.no_permission') {
return 'No Permission';
}
if (perm.startsWith('document.read.')) {
const region = perm.replace('document.read.', '');
return REGION_LABELS[region] || region;
}
if (perm.startsWith('document.write.')) {
const region = perm.replace('document.write.', '');
return REGION_LABELS[region] || region;
}
if (perm.startsWith('tools.core.')) {
const tool = perm.replace('tools.core.', '');
return TOOL_LABELS[tool] || tool;
}
return perm;
}
function isNoPermissionPerm(perm: string): boolean {
return perm === 'document.read.no_permission' || perm === 'document.write.no_permission';
}
function togglePermission(perm: string, group: string) {
const isNoPerm = isNoPermissionPerm(perm);
const noPermSelected = isNoPermissionSelected(group);
if (!isNoPerm && noPermSelected) {
return;
}
if (selectedPermissions.includes(perm)) {
// Uncheck
selectedPermissions = selectedPermissions.filter((p) => p !== perm);
} else {
// Check
if (isNoPerm) {
const prefix = group === 'Document Read' ? 'document.read.' : 'document.write.';
selectedPermissions = selectedPermissions.filter((p) => !p.startsWith(prefix));
selectedPermissions = [...selectedPermissions, perm];
} else {
const noPermPerm =
group === 'Document Read' ? 'document.read.no_permission' : 'document.write.no_permission';
selectedPermissions = selectedPermissions.filter((p) => p !== noPermPerm);
selectedPermissions = [...selectedPermissions, perm];
}
}
}
function selectAllInGroup(group: string, perms: string[]) {
const noPermPerm =
group === 'Document Read' ? 'document.read.no_permission' : 'document.write.no_permission';
const countryPerms = perms.filter((p) => !isNoPermissionPerm(p));
selectedPermissions = selectedPermissions.filter((p) => p !== noPermPerm);
const newPerms = countryPerms.filter((p) => !selectedPermissions.includes(p));
selectedPermissions = [...selectedPermissions, ...newPerms];
}
function deselectAllInGroup(group: string, perms: string[]) {
selectedPermissions = selectedPermissions.filter((p) => !perms.includes(p));
}
async function handleSave() {
const user = $selectedUser;
if (!user) return;
saving = true;
try {
const finalPermissions =
selectedPermissions.length === 0 ? ['no_permission'] : selectedPermissions;
await updateUser(user.uid, selectedRole, finalPermissions);
adminUsers.update((users) =>
users.map((u) =>
u.uid === user.uid ? { ...u, role: selectedRole, permissions: finalPermissions } : u
)
);
addNotification('SUCCESS:User updated successfully');
editSheetOpen.set(false);
} catch (error) {
console.error('Error updating user:', error);
addNotification('ERROR:Failed to update user');
} finally {
saving = false;
}
}
function handleCancel() {
editSheetOpen.set(false);
}
onMount(async () => {
loadingPermissions = true;
try {
availablePermissions = await buildAvailablePermissions();
} catch (error) {
console.error('Error loading permissions:', error);
}
loadingPermissions = false;
});
// Reset form when user changes
$effect(() => {
const user = $selectedUser;
if (user) {
selectedRole = user.role;
// Filter out global no_permission (the old format)
selectedPermissions = user.permissions.filter((p) => p !== 'no_permission');
}
});
</script>
<Sheet.Root bind:open={$editSheetOpen}>
<Sheet.Content side="right" class="w-[400px] p-6 sm:w-[540px]">
<Sheet.Header>
<Sheet.Title>Edit User</Sheet.Title>
<Sheet.Description>Update user role and permissions</Sheet.Description>
</Sheet.Header>
{#if $selectedUser}
<div class="mt-6 space-y-6">
<!-- User Info -->
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-gray-200"
>
{#if $selectedUser.photoURL}
<img
src={$selectedUser.photoURL}
alt={$selectedUser.displayName}
class="h-full w-full object-cover"
/>
{:else}
<User class="h-8 w-8 text-gray-500" />
{/if}
</div>
<div>
<p class="font-medium">{$selectedUser.displayName}</p>
<p class="text-muted-foreground text-sm">{$selectedUser.email}</p>
</div>
</div>
<!-- Role Select -->
<div class="space-y-2">
<Label>Role</Label>
<Select.Root
type="single"
value={selectedRole}
onValueChange={(v) => {
if (v) selectedRole = v as UserRole;
}}
>
<Select.Trigger class="w-full">
{roles.find((r) => r.value === selectedRole)?.label || 'Select role'}
</Select.Trigger>
<Select.Content>
{#each roles as role}
<Select.Item value={role.value}>{role.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Permissions -->
<div class="space-y-4">
<Label>Permissions</Label>
{#if loadingPermissions}
<div class="flex items-center justify-center py-4">
<Spinner class="h-6 w-6" />
</div>
{:else}
<div class="max-h-[400px] space-y-4 overflow-y-auto pr-2">
{#each Object.entries(groupedPermissions()) as [group, perms]}
{#if perms.length > 0}
{@const noPermSelected = isNoPermissionSelected(group)}
{@const hasNoPermOption = group === 'Document Read' || group === 'Document Write'}
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-muted-foreground text-sm font-medium">{group}</p>
<div class="flex gap-1">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
onclick={() => selectAllInGroup(group, perms)}
disabled={noPermSelected}
>
Select All
</Button>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
onclick={() => deselectAllInGroup(group, perms)}
>
Clear
</Button>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
{#each perms as perm}
{@const isNoPerm = isNoPermissionPerm(perm)}
{@const isDisabled = !isNoPerm && noPermSelected}
<label
class="flex items-center gap-2"
class:cursor-pointer={!isDisabled}
class:cursor-not-allowed={isDisabled}
class:opacity-50={isDisabled}
>
<Checkbox
checked={selectedPermissions.includes(perm)}
onCheckedChange={() => togglePermission(perm, group)}
disabled={isDisabled}
/>
<span
class="text-sm"
class:font-medium={isNoPerm}
class:text-red-600={isNoPerm}
>
{getPermissionLabel(perm)}
</span>
</label>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
{/if}
<Sheet.Footer class="mt-6">
<Button variant="outline" onclick={handleCancel} disabled={saving}>Cancel</Button>
<Button onclick={handleSave} disabled={saving}>
{#if saving}
<Spinner class="mr-2 h-4 w-4" />
{/if}
Save Changes
</Button>
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>

View file

@ -9,7 +9,7 @@
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
import { onMount } from 'svelte';
import { onAuthStateChanged } from 'firebase/auth';
import { auth as authStore } from '$lib/core/stores/auth';
import { auth as authStore, authInitialized } from '$lib/core/stores/auth';
import { auth } from '$lib/core/client/firebase';
import { goto } from '$app/navigation';
import { getUserPermission } from '$lib/core/auth/userPermissions';
@ -34,6 +34,7 @@
onAuthStateChanged(auth, async function (s) {
authStore.set(s);
authInitialized.set(true);
if (s) {
if (browser && 'cookieStore' in window) await cookieStore.set('logged_in', 'true');
else {