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

@ -10,16 +10,19 @@
CherryIcon,
DiamondIcon,
BugIcon,
CupSodaIcon
CupSodaIcon,
Shield
} from '@lucide/svelte/icons';
import TaobinLogo from '$lib/assets/logo.svelte';
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/button/button.svelte';
import { get } from 'svelte/store';
import { sidebarStore } from '$lib/core/stores/sidebar';
import { auth } from '$lib/core/stores/auth';
import { isUserAdmin } from '$lib/core/admin/adminService';
let sideBar: HTMLElement | null = $state(null);
let isSideBarOpen: boolean = $state(true);
let isAdmin: boolean = $state(false);
const data = {
navMain: [
@ -68,10 +71,36 @@
}
]
}
// more to add here
]
};
const adminNav = {
title: 'Admin',
items: [
{
title: 'Permissions',
url: '/admin/users',
icon: Shield
}
]
};
$effect(() => {
const currentUser = $auth;
if (currentUser) {
isUserAdmin(currentUser.uid)
.then((result) => {
isAdmin = result;
})
.catch((e) => {
console.error('Error checking admin status:', e);
isAdmin = false;
});
} else {
isAdmin = false;
}
});
function onClickLogoIcon() {
goto('/departments');
}
@ -123,6 +152,30 @@
</Sidebar.GroupContent>
</Sidebar.Group>
{/each}
{#if isAdmin}
<Sidebar.Group>
<Sidebar.GroupLabel>{adminNav.title}</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each adminNav.items as sub}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<a href={sub.url} {...props}>
{#if sub.icon}
<sub.icon />
{/if}
<span>{sub.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
{/if}
</Sidebar.Content>
<Sidebar.Footer>
<AppAccountSelect />

View file

@ -0,0 +1,239 @@
import { doc, getDoc, updateDoc } from 'firebase/firestore';
import { db } from '../client/firebase';
import type {
AdminUser,
DocumentPermissions,
RolesConfig,
ToolsPermissions,
UserRole,
WhitelistConfig
} from './adminTypes';
import { AVAILABLE_REGIONS, AVAILABLE_TOOLS } from './adminTypes';
/**
* Get all users from Firestore
*/
export async function getAllUsers(): Promise<AdminUser[]> {
const docRef = doc(db, 'users', 'data');
const snapshot = await getDoc(docRef);
if (!snapshot.exists()) {
return [];
}
const userData = snapshot.data();
const users: AdminUser[] = [];
for (const uid of Object.keys(userData)) {
const user = userData[uid];
users.push({
uid,
email: user.email || '',
displayName: user.displayName || '',
photoURL: user.photoURL || '',
role: user.role || 'guest',
permissions: user.permissions || ['no_permission'],
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt
});
}
return users;
}
/**
* Get a single user by UID
*/
export async function getUserByUid(uid: string): Promise<AdminUser | null> {
const docRef = doc(db, 'users', 'data');
const snapshot = await getDoc(docRef);
if (!snapshot.exists()) {
return null;
}
const userData = snapshot.data();
if (!userData[uid]) {
return null;
}
const user = userData[uid];
return {
uid,
email: user.email || '',
displayName: user.displayName || '',
photoURL: user.photoURL || '',
role: user.role || 'guest',
permissions: user.permissions || ['no_permission'],
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt
};
}
/**
* Update user role
*/
export async function updateUserRole(uid: string, role: UserRole): Promise<void> {
const docRef = doc(db, 'users', 'data');
const updateData: Record<string, unknown> = {};
updateData[`${uid}.role`] = role;
await updateDoc(docRef, updateData);
}
/**
* Update user permissions
*/
export async function updateUserPermissions(uid: string, permissions: string[]): Promise<void> {
const docRef = doc(db, 'users', 'data');
const updateData: Record<string, unknown> = {};
updateData[`${uid}.permissions`] = permissions;
await updateDoc(docRef, updateData);
}
/**
* Update user role and permissions together
*/
export async function updateUser(
uid: string,
role: UserRole,
permissions: string[]
): Promise<void> {
const docRef = doc(db, 'users', 'data');
const updateData: Record<string, unknown> = {};
updateData[`${uid}.role`] = role;
updateData[`${uid}.permissions`] = permissions;
await updateDoc(docRef, updateData);
}
/**
* Get role definitions from Firestore
*/
export async function getRoleDefinitions(): Promise<RolesConfig | null> {
const docRef = doc(db, 'roles', 'v1');
const snapshot = await getDoc(docRef);
if (!snapshot.exists()) {
return null;
}
const data = snapshot.data();
return {
definitions: {
guest: data.definitions?.guest || { permissions: ['no_permission'] },
viewer: data.definitions?.viewer || { permissions: [] },
admin: data.definitions?.admin || { permissions: [] }
}
};
}
/**
* Update role definition
*/
export async function updateRoleDefinition(role: UserRole, permissions: string[]): Promise<void> {
const docRef = doc(db, 'roles', 'v1');
const updateData: Record<string, unknown> = {};
updateData[`definitions.${role}.permissions`] = permissions;
await updateDoc(docRef, updateData);
}
/**
* Get document permissions (regions)
*/
export async function getDocumentPermissions(): Promise<DocumentPermissions | null> {
const docRef = doc(db, 'permissions', 'document');
const snapshot = await getDoc(docRef);
if (!snapshot.exists()) {
return null;
}
const data = snapshot.data();
return {
read: data.read || [],
write: data.write || []
};
}
/**
* Get tools permissions
*/
export async function getToolsPermissions(): Promise<ToolsPermissions | null> {
const docRef = doc(db, 'permissions', 'tools');
const snapshot = await getDoc(docRef);
if (!snapshot.exists()) {
return null;
}
const data = snapshot.data();
return {
core: data.core || []
};
}
/**
* Get allowed domains (whitelist)
*/
export async function getAllowedDomains(): Promise<string[]> {
const docRef = doc(db, 'whitelist', 'allowedDomains');
const snapshot = await getDoc(docRef);
if (!snapshot.exists()) {
return [];
}
const data = snapshot.data() as WhitelistConfig;
return data.account_email || [];
}
/**
* Update allowed domains (whitelist)
*/
export async function updateAllowedDomains(domains: string[]): Promise<void> {
const docRef = doc(db, 'whitelist', 'allowedDomains');
await updateDoc(docRef, { account_email: domains });
}
/**
* Build all available permissions based on regions and tools
* Uses AVAILABLE_REGIONS and AVAILABLE_TOOLS as fallback if Firestore data is empty
* no_permission is expected to be in Firebase data already
*/
export async function buildAvailablePermissions(): Promise<string[]> {
const permissions: string[] = [];
const docPerms = await getDocumentPermissions();
// Document Read permissions (no_permission should be in Firebase read array)
const readRegions = docPerms?.read?.length ? docPerms.read : [...AVAILABLE_REGIONS];
for (const region of readRegions) {
permissions.push(`document.read.${region}`);
}
// Document Write permissions (no_permission should be in Firebase write array)
const writeRegions = docPerms?.write?.length ? docPerms.write : [...AVAILABLE_REGIONS];
for (const region of writeRegions) {
permissions.push(`document.write.${region}`);
}
// Tools permissions
const toolPerms = await getToolsPermissions();
const tools = toolPerms?.core?.length ? toolPerms.core : [...AVAILABLE_TOOLS];
for (const tool of tools) {
permissions.push(`tools.core.${tool}`);
}
return permissions;
}
/**
* Check if current user is admin
*/
export async function isUserAdmin(uid: string): Promise<boolean> {
const user = await getUserByUid(uid);
return user?.role === 'admin';
}

View file

@ -0,0 +1,25 @@
import { writable } from 'svelte/store';
import type { AdminUser, RolesConfig } from './adminTypes';
// Users list store
export const adminUsers = writable<AdminUser[]>([]);
// Roles configuration store
export const rolesConfig = writable<RolesConfig | null>(null);
// Available permissions store
export const availablePermissions = writable<string[]>([]);
// Loading states
export const adminLoading = writable<boolean>(false);
export const usersLoading = writable<boolean>(false);
export const rolesLoading = writable<boolean>(false);
// Error state
export const adminError = writable<string | null>(null);
// Selected user for editing
export const selectedUser = writable<AdminUser | null>(null);
// Edit sheet open state
export const editSheetOpen = writable<boolean>(false);

View file

@ -0,0 +1,72 @@
export interface AdminUser {
uid: string;
email: string;
displayName: string;
photoURL: string;
role: 'guest' | 'viewer' | 'admin';
permissions: string[];
lastLoginAt?: string;
createdAt?: string;
}
export interface RoleDefinition {
permissions: string[];
}
export interface RolesConfig {
definitions: {
guest: RoleDefinition;
viewer: RoleDefinition;
admin: RoleDefinition;
};
}
export interface DocumentPermissions {
read: string[];
write: string[];
}
export interface ToolsPermissions {
core: string[];
}
export interface WhitelistConfig {
account_email: string[];
}
export type UserRole = 'guest' | 'viewer' | 'admin';
export const AVAILABLE_REGIONS = [
'tha',
'mys',
'aus',
'sgp',
'uae_dubai',
'hkg',
'gbr',
'rou',
'lva',
'est',
'ltu'
] as const;
export const REGION_LABELS: Record<string, string> = {
tha: 'Thailand',
mys: 'Malaysia',
aus: 'Australia',
sgp: 'Singapore',
uae_dubai: 'UAE Dubai',
hkg: 'Hong Kong',
gbr: 'United Kingdom',
rou: 'Romania',
lva: 'Latvia',
est: 'Estonia',
ltu: 'Lithuania'
};
export const AVAILABLE_TOOLS = ['connectMachine', 'allowAdbWebToUsb'] as const;
export const TOOL_LABELS: Record<string, string> = {
connectMachine: 'Connect Machine',
allowAdbWebToUsb: 'Allow ADB Web to USB'
};

View file

@ -6,4 +6,6 @@ import { writable } from "svelte/store";
// email: string,
// };
export const auth = writable<User | null>(null);
export const auth = writable<User | null>(null);
export const authInitialized = writable<boolean>(false);

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 {