diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index e4d0917..8b46cd7 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -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 @@ {/each} + + {#if isAdmin} + + {adminNav.title} + + + {#each adminNav.items as sub} + + + {#snippet child({ props })} + + {#if sub.icon} + + {/if} + {sub.title} + + {/snippet} + + + {/each} + + + + {/if} diff --git a/src/lib/core/admin/adminService.ts b/src/lib/core/admin/adminService.ts new file mode 100644 index 0000000..5be4a2d --- /dev/null +++ b/src/lib/core/admin/adminService.ts @@ -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 { + 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 { + 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 { + const docRef = doc(db, 'users', 'data'); + const updateData: Record = {}; + updateData[`${uid}.role`] = role; + + await updateDoc(docRef, updateData); +} + +/** + * Update user permissions + */ +export async function updateUserPermissions(uid: string, permissions: string[]): Promise { + const docRef = doc(db, 'users', 'data'); + const updateData: Record = {}; + 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 { + const docRef = doc(db, 'users', 'data'); + const updateData: Record = {}; + updateData[`${uid}.role`] = role; + updateData[`${uid}.permissions`] = permissions; + + await updateDoc(docRef, updateData); +} + +/** + * Get role definitions from Firestore + */ +export async function getRoleDefinitions(): Promise { + 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 { + const docRef = doc(db, 'roles', 'v1'); + const updateData: Record = {}; + updateData[`definitions.${role}.permissions`] = permissions; + + await updateDoc(docRef, updateData); +} + +/** + * Get document permissions (regions) + */ +export async function getDocumentPermissions(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const user = await getUserByUid(uid); + return user?.role === 'admin'; +} diff --git a/src/lib/core/admin/adminStore.ts b/src/lib/core/admin/adminStore.ts new file mode 100644 index 0000000..8e0c03c --- /dev/null +++ b/src/lib/core/admin/adminStore.ts @@ -0,0 +1,25 @@ +import { writable } from 'svelte/store'; +import type { AdminUser, RolesConfig } from './adminTypes'; + +// Users list store +export const adminUsers = writable([]); + +// Roles configuration store +export const rolesConfig = writable(null); + +// Available permissions store +export const availablePermissions = writable([]); + +// Loading states +export const adminLoading = writable(false); +export const usersLoading = writable(false); +export const rolesLoading = writable(false); + +// Error state +export const adminError = writable(null); + +// Selected user for editing +export const selectedUser = writable(null); + +// Edit sheet open state +export const editSheetOpen = writable(false); diff --git a/src/lib/core/admin/adminTypes.ts b/src/lib/core/admin/adminTypes.ts new file mode 100644 index 0000000..cb8b7df --- /dev/null +++ b/src/lib/core/admin/adminTypes.ts @@ -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 = { + 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 = { + connectMachine: 'Connect Machine', + allowAdbWebToUsb: 'Allow ADB Web to USB' +}; diff --git a/src/lib/core/stores/auth.ts b/src/lib/core/stores/auth.ts index 3f1c0ec..bd97e46 100644 --- a/src/lib/core/stores/auth.ts +++ b/src/lib/core/stores/auth.ts @@ -6,4 +6,6 @@ import { writable } from "svelte/store"; // email: string, // }; -export const auth = writable(null); \ No newline at end of file +export const auth = writable(null); + +export const authInitialized = writable(false); \ No newline at end of file diff --git a/src/routes/(authed)/admin/+layout.server.ts b/src/routes/(authed)/admin/+layout.server.ts new file mode 100644 index 0000000..224d765 --- /dev/null +++ b/src/routes/(authed)/admin/+layout.server.ts @@ -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 +} diff --git a/src/routes/(authed)/admin/+layout.svelte b/src/routes/(authed)/admin/+layout.svelte new file mode 100644 index 0000000..3730cf4 --- /dev/null +++ b/src/routes/(authed)/admin/+layout.svelte @@ -0,0 +1,103 @@ + + +{#if loading} +
+ +
+{:else if isAdmin} +
+
+

Admin Panel

+

Manage users, roles, and system settings

+
+ + + + + + Users + + + + Roles + + + + Settings + + + +
+ {@render children()} +
+
+
+{:else} +
+

Access denied. Admin privileges required.

+
+{/if} diff --git a/src/routes/(authed)/admin/+page.svelte b/src/routes/(authed)/admin/+page.svelte new file mode 100644 index 0000000..2de555e --- /dev/null +++ b/src/routes/(authed)/admin/+page.svelte @@ -0,0 +1,12 @@ + + +
+

Redirecting...

+
diff --git a/src/routes/(authed)/admin/roles/+page.svelte b/src/routes/(authed)/admin/roles/+page.svelte new file mode 100644 index 0000000..28be91e --- /dev/null +++ b/src/routes/(authed)/admin/roles/+page.svelte @@ -0,0 +1,72 @@ + + +
+
+
+

Roles

+

View and manage role definitions

+
+ +
+ + {#if $rolesLoading} +
+ +
+ {:else if $rolesConfig} +
+ {#each roleOrder as role} + + {/each} +
+ {:else} +
+

No role definitions found.

+
+ {/if} +
diff --git a/src/routes/(authed)/admin/roles/role-card.svelte b/src/routes/(authed)/admin/roles/role-card.svelte new file mode 100644 index 0000000..3251b4f --- /dev/null +++ b/src/routes/(authed)/admin/roles/role-card.svelte @@ -0,0 +1,195 @@ + + + + +
+
+
+ +
+ {role} +
+ +
+ {description} +
+ +
+

+ {displayPermissions.length} permission(s) +

+
+ {#if displayPermissions.length === 0} + No permissions + {:else} + {#each displayPermissions.slice(0, 5) as perm} + {getPermissionLabel(perm)} + {/each} + {#if displayPermissions.length > 5} + +{displayPermissions.length - 5} more + {/if} + {/if} +
+
+
+
+ + + + + Edit {role} Role + Select permissions for this role + + +
+ {#each Object.entries(groupedAvailable) as [group, perms]} + {#if perms.length > 0} +
+

{group}

+
+ {#each perms as perm} + + {/each} +
+
+ {/if} + {/each} +
+ + + + + +
+
diff --git a/src/routes/(authed)/admin/settings/+page.svelte b/src/routes/(authed)/admin/settings/+page.svelte new file mode 100644 index 0000000..62c1f91 --- /dev/null +++ b/src/routes/(authed)/admin/settings/+page.svelte @@ -0,0 +1,142 @@ + + +
+
+
+

Settings

+

View system configuration (read-only)

+
+ +
+ + {#if loading} +
+ +
+ {:else} +
+ + + +
+ + Document Regions +
+ Available regions for document access +
+ + {#if documentPerms} +
+

Read Access

+
+ {#each documentPerms.read as region} + {REGION_LABELS[region] || region} + {/each} +
+
+
+

Write Access

+
+ {#each documentPerms.write as region} + {REGION_LABELS[region] || region} + {/each} +
+
+ {:else} +

No document permissions configured

+ {/if} +
+
+ + + + +
+ + Tools Permissions +
+ Available tool permissions +
+ + {#if toolsPerms} +
+

Core Tools

+
+ {#each toolsPerms.core as tool} + {TOOL_LABELS[tool] || tool} + {/each} +
+
+ {:else} +

No tool permissions configured

+ {/if} +
+
+ + + + +
+ + Email Domain Whitelist +
+ Allowed email domains for login +
+ + {#if allowedDomains.length > 0} +
+ {#each allowedDomains as domain} + @{domain} + {/each} +
+ {:else} +

No domains configured

+ {/if} +
+
+
+ {/if} +
diff --git a/src/routes/(authed)/admin/users/+page.svelte b/src/routes/(authed)/admin/users/+page.svelte new file mode 100644 index 0000000..a551aa7 --- /dev/null +++ b/src/routes/(authed)/admin/users/+page.svelte @@ -0,0 +1,56 @@ + + +
+
+
+

Users

+

Manage user roles and permissions

+
+ +
+ + {#if $usersLoading} +
+ +
+ {:else if $adminError} +
+

{$adminError}

+
+ {:else} + + {/if} +
+ + diff --git a/src/routes/(authed)/admin/users/columns.ts b/src/routes/(authed)/admin/users/columns.ts new file mode 100644 index 0000000..a8388bc --- /dev/null +++ b/src/routes/(authed)/admin/users/columns.ts @@ -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[] = [ + { + 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 }); + } + } +]; diff --git a/src/routes/(authed)/admin/users/data-table-actions.svelte b/src/routes/(authed)/admin/users/data-table-actions.svelte new file mode 100644 index 0000000..866f207 --- /dev/null +++ b/src/routes/(authed)/admin/users/data-table-actions.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/routes/(authed)/admin/users/data-table-avatar.svelte b/src/routes/(authed)/admin/users/data-table-avatar.svelte new file mode 100644 index 0000000..a617cb7 --- /dev/null +++ b/src/routes/(authed)/admin/users/data-table-avatar.svelte @@ -0,0 +1,13 @@ + + +
+ {#if photoURL} + {displayName} + {:else} + + {/if} +
diff --git a/src/routes/(authed)/admin/users/data-table-role-badge.svelte b/src/routes/(authed)/admin/users/data-table-role-badge.svelte new file mode 100644 index 0000000..145eb6a --- /dev/null +++ b/src/routes/(authed)/admin/users/data-table-role-badge.svelte @@ -0,0 +1,22 @@ + + + + {roleLabels[role] || role} + diff --git a/src/routes/(authed)/admin/users/data-table.svelte b/src/routes/(authed)/admin/users/data-table.svelte new file mode 100644 index 0000000..1ee4702 --- /dev/null +++ b/src/routes/(authed)/admin/users/data-table.svelte @@ -0,0 +1,160 @@ + + +
+
+ + { + table.setGlobalFilter(e.currentTarget.value); + }} + oninput={(e) => { + table.setGlobalFilter(e.currentTarget.value); + }} + /> +
+ +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + No users found. + + {/each} + + +
+ + {table.getFilteredRowModel().rows.length} user(s) total + +
+ + +
+
+
+
diff --git a/src/routes/(authed)/admin/users/user-edit-sheet.svelte b/src/routes/(authed)/admin/users/user-edit-sheet.svelte new file mode 100644 index 0000000..35251bd --- /dev/null +++ b/src/routes/(authed)/admin/users/user-edit-sheet.svelte @@ -0,0 +1,309 @@ + + + + + + Edit User + Update user role and permissions + + + {#if $selectedUser} +
+ +
+
+ {#if $selectedUser.photoURL} + {$selectedUser.displayName} + {:else} + + {/if} +
+
+

{$selectedUser.displayName}

+

{$selectedUser.email}

+
+
+ + +
+ + { + if (v) selectedRole = v as UserRole; + }} + > + + {roles.find((r) => r.value === selectedRole)?.label || 'Select role'} + + + {#each roles as role} + {role.label} + {/each} + + +
+ + +
+ + + {#if loadingPermissions} +
+ +
+ {:else} +
+ {#each Object.entries(groupedPermissions()) as [group, perms]} + {#if perms.length > 0} + {@const noPermSelected = isNoPermissionSelected(group)} + {@const hasNoPermOption = group === 'Document Read' || group === 'Document Write'} +
+
+

{group}

+
+ + +
+
+ +
+ {#each perms as perm} + {@const isNoPerm = isNoPermissionPerm(perm)} + {@const isDisabled = !isNoPerm && noPermSelected} + + {/each} +
+
+ {/if} + {/each} +
+ {/if} +
+
+ {/if} + + + + + +
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9694127..8e13784 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 {