add admin permission
This commit is contained in:
parent
3388eca2fe
commit
7ea73543b7
19 changed files with 1567 additions and 5 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
239
src/lib/core/admin/adminService.ts
Normal file
239
src/lib/core/admin/adminService.ts
Normal 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';
|
||||
}
|
||||
25
src/lib/core/admin/adminStore.ts
Normal file
25
src/lib/core/admin/adminStore.ts
Normal 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);
|
||||
72
src/lib/core/admin/adminTypes.ts
Normal file
72
src/lib/core/admin/adminTypes.ts
Normal 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'
|
||||
};
|
||||
|
|
@ -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);
|
||||
11
src/routes/(authed)/admin/+layout.server.ts
Normal file
11
src/routes/(authed)/admin/+layout.server.ts
Normal 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
|
||||
}
|
||||
103
src/routes/(authed)/admin/+layout.svelte
Normal file
103
src/routes/(authed)/admin/+layout.svelte
Normal 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}
|
||||
12
src/routes/(authed)/admin/+page.svelte
Normal file
12
src/routes/(authed)/admin/+page.svelte
Normal 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>
|
||||
72
src/routes/(authed)/admin/roles/+page.svelte
Normal file
72
src/routes/(authed)/admin/roles/+page.svelte
Normal 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>
|
||||
195
src/routes/(authed)/admin/roles/role-card.svelte
Normal file
195
src/routes/(authed)/admin/roles/role-card.svelte
Normal 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>
|
||||
142
src/routes/(authed)/admin/settings/+page.svelte
Normal file
142
src/routes/(authed)/admin/settings/+page.svelte
Normal 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>
|
||||
56
src/routes/(authed)/admin/users/+page.svelte
Normal file
56
src/routes/(authed)/admin/users/+page.svelte
Normal 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 />
|
||||
57
src/routes/(authed)/admin/users/columns.ts
Normal file
57
src/routes/(authed)/admin/users/columns.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
];
|
||||
18
src/routes/(authed)/admin/users/data-table-actions.svelte
Normal file
18
src/routes/(authed)/admin/users/data-table-actions.svelte
Normal 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>
|
||||
13
src/routes/(authed)/admin/users/data-table-avatar.svelte
Normal file
13
src/routes/(authed)/admin/users/data-table-avatar.svelte
Normal 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>
|
||||
22
src/routes/(authed)/admin/users/data-table-role-badge.svelte
Normal file
22
src/routes/(authed)/admin/users/data-table-role-badge.svelte
Normal 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>
|
||||
160
src/routes/(authed)/admin/users/data-table.svelte
Normal file
160
src/routes/(authed)/admin/users/data-table.svelte
Normal 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>
|
||||
309
src/routes/(authed)/admin/users/user-edit-sheet.svelte
Normal file
309
src/routes/(authed)/admin/users/user-edit-sheet.svelte
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue