diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..82f3b93 --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -0,0 +1,271 @@ +// Authentication API client + +import { browser } from '$app/environment'; +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + User, + ChangePasswordRequest, + AdminUser, + Role, + CreateUserRequest, + UpdateUserRequest, + CreateRoleRequest, + UpdateRoleRequest +} from '$lib/types/auth'; +import { authStore } from '$lib/stores/auth'; +import { get } from 'svelte/store'; + +const API_BASE = '/api/v1'; + +// Get auth token from store +function getToken(): string | null { + if (!browser) return null; + const auth = get(authStore); + return auth.token; +} + +// Make authenticated API request +async function authFetch(path: string, options: RequestInit = {}): Promise { + const token = getToken(); + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}) + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers + }); + + // Handle 401 - token expired or invalid + if (response.status === 401 && token) { + authStore.logout(); + } + + return response; +} + +// Auth API +export const authApi = { + async login(credentials: LoginRequest): Promise { + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Login failed' })); + throw new Error(error.error || 'Login failed'); + } + + return response.json(); + }, + + async logout(): Promise { + await authFetch('/auth/logout', { method: 'POST' }).catch(() => {}); + }, + + async logoutAll(): Promise { + const response = await authFetch('/auth/logout/all', { method: 'POST' }); + if (!response.ok) { + throw new Error('Failed to logout all sessions'); + } + }, + + async register(data: RegisterRequest): Promise { + const response = await fetch(`${API_BASE}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Registration failed' })); + throw new Error(error.error || 'Registration failed'); + } + + return response.json(); + }, + + async getCurrentUser(): Promise { + const response = await authFetch('/auth/me'); + if (!response.ok) { + throw new Error('Failed to get current user'); + } + return response.json(); + }, + + async updateProfile(data: { email?: string }): Promise { + const response = await authFetch('/auth/me', { + method: 'PUT', + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error('Failed to update profile'); + } + }, + + async changePassword(data: ChangePasswordRequest): Promise { + const response = await authFetch('/auth/me/password', { + method: 'PUT', + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to change password' })); + throw new Error(error.error || 'Failed to change password'); + } + } +}; + +// Users Admin API +export const usersApi = { + async list(): Promise { + const response = await authFetch('/users'); + if (!response.ok) { + throw new Error('Failed to list users'); + } + return response.json(); + }, + + async get(id: string): Promise { + const response = await authFetch(`/users/${id}`); + if (!response.ok) { + throw new Error('Failed to get user'); + } + return response.json(); + }, + + async create(data: CreateUserRequest): Promise { + const response = await authFetch('/users', { + method: 'POST', + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to create user' })); + throw new Error(error.error || 'Failed to create user'); + } + + return response.json(); + }, + + async update(id: string, data: UpdateUserRequest): Promise { + const response = await authFetch(`/users/${id}`, { + method: 'PUT', + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error('Failed to update user'); + } + + return response.json(); + }, + + async disable(id: string): Promise { + const response = await authFetch(`/users/${id}`, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Failed to disable user'); + } + }, + + async enable(id: string): Promise { + const response = await authFetch(`/users/${id}/enable`, { method: 'POST' }); + if (!response.ok) { + throw new Error('Failed to enable user'); + } + }, + + async resetPassword(id: string, newPassword: string): Promise { + const response = await authFetch(`/users/${id}/reset-password`, { + method: 'POST', + body: JSON.stringify({ newPassword }) + }); + + if (!response.ok) { + throw new Error('Failed to reset password'); + } + }, + + async getRoles(id: string): Promise { + const response = await authFetch(`/users/${id}/roles`); + if (!response.ok) { + throw new Error('Failed to get user roles'); + } + return response.json(); + }, + + async setRoles(id: string, roles: string[]): Promise { + const response = await authFetch(`/users/${id}/roles`, { + method: 'PUT', + body: JSON.stringify({ roles }) + }); + + if (!response.ok) { + throw new Error('Failed to set user roles'); + } + } +}; + +// Roles Admin API +export const rolesApi = { + async list(): Promise { + const response = await authFetch('/roles'); + if (!response.ok) { + throw new Error('Failed to list roles'); + } + return response.json(); + }, + + async get(id: string): Promise { + const response = await authFetch(`/roles/${id}`); + if (!response.ok) { + throw new Error('Failed to get role'); + } + return response.json(); + }, + + async create(data: CreateRoleRequest): Promise { + const response = await authFetch('/roles', { + method: 'POST', + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to create role' })); + throw new Error(error.error || 'Failed to create role'); + } + + return response.json(); + }, + + async update(id: string, data: UpdateRoleRequest): Promise { + const response = await authFetch(`/roles/${id}`, { + method: 'PUT', + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error('Failed to update role'); + } + + return response.json(); + }, + + async delete(id: string): Promise { + const response = await authFetch(`/roles/${id}`, { method: 'DELETE' }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Failed to delete role' })); + throw new Error(error.error || 'Failed to delete role'); + } + } +}; diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte index 0660be0..b5522b6 100644 --- a/frontend/src/lib/components/Header.svelte +++ b/frontend/src/lib/components/Header.svelte @@ -7,6 +7,7 @@ import { hosts } from '$lib/stores/hosts'; import { formatUptime } from '$lib/utils/formatters'; import HostSelector from './HostSelector.svelte'; + import UserMenu from './auth/UserMenu.svelte'; const refreshRates = [1, 2, 5, 10, 30]; @@ -136,6 +137,9 @@ {$connected ? 'Live' : 'Offline'} + + + @@ -236,6 +240,11 @@ + + +
+ +
{/if} diff --git a/frontend/src/lib/components/auth/UserMenu.svelte b/frontend/src/lib/components/auth/UserMenu.svelte new file mode 100644 index 0000000..0804b78 --- /dev/null +++ b/frontend/src/lib/components/auth/UserMenu.svelte @@ -0,0 +1,238 @@ + + + + +{#if $isAuthenticated && $currentUser} +
+ + + {#if isOpen} + + {/if} +
+{:else} + +{/if} + + diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts new file mode 100644 index 0000000..8c409ca --- /dev/null +++ b/frontend/src/lib/stores/auth.ts @@ -0,0 +1,209 @@ +// Authentication store + +import { writable, derived, get } from 'svelte/store'; +import { browser } from '$app/environment'; +import type { User, Permission, LoginResponse } from '$lib/types/auth'; + +const STORAGE_KEY = 'tyto_auth'; + +interface AuthState { + token: string | null; + user: User | null; + expiresAt: Date | null; + isLoading: boolean; +} + +// Load initial state from localStorage +function loadInitialState(): AuthState { + if (!browser) { + return { token: null, user: null, expiresAt: null, isLoading: true }; + } + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const data = JSON.parse(stored); + const expiresAt = new Date(data.expiresAt); + + // Check if token is expired + if (expiresAt > new Date()) { + return { + token: data.token, + user: data.user, + expiresAt, + isLoading: false + }; + } + } + } catch { + // Invalid stored data + } + + return { token: null, user: null, expiresAt: null, isLoading: false }; +} + +function createAuthStore() { + const { subscribe, set, update } = writable(loadInitialState()); + + return { + subscribe, + + // Set auth state from login response + setAuth(response: LoginResponse) { + const state: AuthState = { + token: response.token, + user: response.user, + expiresAt: new Date(response.expiresAt), + isLoading: false + }; + + // Persist to localStorage + if (browser) { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + token: response.token, + user: response.user, + expiresAt: response.expiresAt + }) + ); + } + + set(state); + }, + + // Update user info + updateUser(user: User) { + update((state) => { + const newState = { ...state, user }; + + // Update localStorage + if (browser && state.token) { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + token: state.token, + user, + expiresAt: state.expiresAt?.toISOString() + }) + ); + } + + return newState; + }); + }, + + // Clear auth state (logout) + logout() { + if (browser) { + localStorage.removeItem(STORAGE_KEY); + } + set({ token: null, user: null, expiresAt: null, isLoading: false }); + }, + + // Set loading state + setLoading(isLoading: boolean) { + update((state) => ({ ...state, isLoading })); + }, + + // Check if token is valid + isTokenValid(): boolean { + const state = get({ subscribe }); + if (!state.token || !state.expiresAt) return false; + return state.expiresAt > new Date(); + } + }; +} + +export const authStore = createAuthStore(); + +// Derived stores +export const isAuthenticated = derived(authStore, ($auth) => !!$auth.token && !!$auth.user); + +export const currentUser = derived(authStore, ($auth) => $auth.user); + +export const isLoading = derived(authStore, ($auth) => $auth.isLoading); + +// Permission checking +export function hasPermission(perm: Permission): boolean { + const auth = get(authStore); + if (!auth.user) return false; + + // Get permissions from roles + // For now, we check role names - in production, the backend sends permissions + const roles = auth.user.roles || []; + + // Admin has all permissions + if (roles.includes('admin') || roles.includes('Administrator')) { + return true; + } + + // Map permissions to roles (simplified) + const rolePermissions: Record = { + operator: [ + 'dashboard:view', + 'agents:view', + 'agents:manage', + 'alerts:view', + 'alerts:acknowledge', + 'alerts:configure', + 'metrics:query', + 'metrics:export' + ], + viewer: ['dashboard:view', 'agents:view', 'alerts:view'] + }; + + for (const role of roles) { + const perms = rolePermissions[role.toLowerCase()]; + if (perms?.includes(perm)) { + return true; + } + } + + return false; +} + +// Check if user has any of the permissions +export function hasAnyPermission(...perms: Permission[]): boolean { + return perms.some((p) => hasPermission(p)); +} + +// Check if user has all permissions +export function hasAllPermissions(...perms: Permission[]): boolean { + return perms.every((p) => hasPermission(p)); +} + +// Reactive permission check (for use in components) +export function canAccess(perm: Permission) { + return derived(authStore, ($auth) => { + if (!$auth.user) return false; + const roles = $auth.user.roles || []; + + if (roles.includes('admin') || roles.includes('Administrator')) { + return true; + } + + const rolePermissions: Record = { + operator: [ + 'dashboard:view', + 'agents:view', + 'agents:manage', + 'alerts:view', + 'alerts:acknowledge', + 'alerts:configure', + 'metrics:query', + 'metrics:export' + ], + viewer: ['dashboard:view', 'agents:view', 'alerts:view'] + }; + + for (const role of roles) { + const perms = rolePermissions[role.toLowerCase()]; + if (perms?.includes(perm)) { + return true; + } + } + + return false; + }); +} diff --git a/frontend/src/lib/types/auth.ts b/frontend/src/lib/types/auth.ts new file mode 100644 index 0000000..872d581 --- /dev/null +++ b/frontend/src/lib/types/auth.ts @@ -0,0 +1,105 @@ +// Authentication types + +export interface User { + id: string; + username: string; + email?: string; + authProvider: 'local' | 'ldap'; + roles: string[]; + createdAt: string; + lastLogin?: string; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface LoginResponse { + token: string; + expiresAt: string; + user: User; +} + +export interface RegisterRequest { + username: string; + password: string; + email?: string; +} + +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; +} + +export interface Session { + token: string; + expiresAt: Date; + user: User; +} + +// Permission type +export type Permission = + | 'dashboard:view' + | 'agents:view' + | 'agents:manage' + | 'alerts:view' + | 'alerts:acknowledge' + | 'alerts:configure' + | 'metrics:query' + | 'metrics:export' + | 'users:view' + | 'users:manage' + | 'roles:view' + | 'roles:manage' + | 'settings:view' + | 'settings:manage' + | 'pki:manage' + | 'audit:view'; + +// Role type +export interface Role { + id: string; + name: string; + description?: string; + permissions: string[]; + isSystem: boolean; + createdAt: string; +} + +// Admin user type (includes more fields) +export interface AdminUser { + id: string; + username: string; + email?: string; + authProvider: 'local' | 'ldap'; + roles: string[]; + disabled: boolean; + createdAt: string; + lastLogin?: string; +} + +export interface CreateUserRequest { + username: string; + password?: string; + email?: string; + roles?: string[]; + disabled?: boolean; +} + +export interface UpdateUserRequest { + email?: string; + disabled?: boolean; +} + +export interface CreateRoleRequest { + name: string; + description?: string; + permissions: string[]; +} + +export interface UpdateRoleRequest { + name: string; + description?: string; + permissions: string[]; +} diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..bb3b822 --- /dev/null +++ b/frontend/src/routes/admin/+layout.svelte @@ -0,0 +1,140 @@ + + +{#if $isAuthenticated} +
+ + +
+ {@render children()} +
+
+{/if} + + diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..37a806a --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,41 @@ + + + + Admin - Tyto + + +
+

Administration

+

Select a section from the navigation above.

+
+ + diff --git a/frontend/src/routes/admin/roles/+page.svelte b/frontend/src/routes/admin/roles/+page.svelte new file mode 100644 index 0000000..826df79 --- /dev/null +++ b/frontend/src/routes/admin/roles/+page.svelte @@ -0,0 +1,845 @@ + + + + Roles - Admin - Tyto + + +
+ + + {#if error} +
{error}
+ {/if} + + {#if isLoading} +
Loading roles...
+ {:else} +
+ {#each roles as role} +
+
+
+

{role.name}

+ {#if role.isSystem} + System + {/if} +
+ {#if hasPermission('roles:manage') && !role.isSystem} +
+ + +
+ {/if} +
+ + {#if role.description} +

{role.description}

+ {/if} + +
+

Permissions ({role.permissions.length})

+ {#if role.permissions.includes('*' as Permission)} + All Permissions + {:else} +
+ {#each role.permissions.slice(0, 6) as perm} + {perm} + {/each} + {#if role.permissions.length > 6} + +{role.permissions.length - 6} more + {/if} +
+ {/if} +
+
+ {/each} +
+ {/if} +
+ + +{#if showCreateModal} + +{/if} + + +{#if showEditModal && editingRole} + +{/if} + + +{#if showDeleteModal && editingRole} + +{/if} + + diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..e416379 --- /dev/null +++ b/frontend/src/routes/admin/users/+page.svelte @@ -0,0 +1,715 @@ + + + + Users - Admin - Tyto + + +
+ + + {#if error} +
{error}
+ {/if} + + {#if isLoading} +
Loading users...
+ {:else} +
+ + + + + + + + + + + + + {#each users as user} + + + + + + + + + {/each} + +
UsernameEmailRolesStatusLast LoginActions
+
+ {user.username.charAt(0).toUpperCase()} +
+ {user.username} +
{user.email || '-'} +
+ {#each user.roles as role} + {role} + {/each} + {#if user.roles.length === 0} + No roles + {/if} +
+
+ + {user.disabled ? 'Disabled' : 'Active'} + + {formatDate(user.lastLogin)} + {#if hasPermission('users:manage')} +
+ + + +
+ {/if} +
+
+ {/if} +
+ + +{#if showCreateModal} + +{/if} + + +{#if showEditModal && editingUser} + +{/if} + + +{#if showPasswordModal && editingUser} + +{/if} + + diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..0e0ab0d --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,229 @@ + + + + Login - Tyto + + + + +