feat(frontend): add authentication UI and admin pages

Frontend Auth System:
- Add auth types (User, Role, Permission, LoginResponse)
- Add auth API client with token injection (authApi, usersApi, rolesApi)
- Add auth store with localStorage persistence and expiration
- Add hasPermission/hasAnyPermission permission checks

Login Page:
- Create /login route with username/password form
- Auto-redirect if already authenticated
- Loading states and error handling

Admin Pages:
- Add /admin layout with route guards ($effect-based)
- Create users management page with CRUD modals
- Create roles management page with permission editor
- Category-based permission selection UI

Header Integration:
- Add UserMenu component with dropdown
- Show admin link for users with admin permissions
- Show Sign In link when not authenticated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 08:40:59 +01:00
parent 7b746643c7
commit c0dbf80521
10 changed files with 2802 additions and 0 deletions

View File

@@ -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<Response> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {})
};
if (token) {
(headers as Record<string, string>)['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<LoginResponse> {
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<void> {
await authFetch('/auth/logout', { method: 'POST' }).catch(() => {});
},
async logoutAll(): Promise<void> {
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<User> {
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<User> {
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<void> {
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<void> {
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<AdminUser[]> {
const response = await authFetch('/users');
if (!response.ok) {
throw new Error('Failed to list users');
}
return response.json();
},
async get(id: string): Promise<AdminUser> {
const response = await authFetch(`/users/${id}`);
if (!response.ok) {
throw new Error('Failed to get user');
}
return response.json();
},
async create(data: CreateUserRequest): Promise<AdminUser> {
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<AdminUser> {
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<void> {
const response = await authFetch(`/users/${id}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Failed to disable user');
}
},
async enable(id: string): Promise<void> {
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<void> {
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<Role[]> {
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<void> {
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<Role[]> {
const response = await authFetch('/roles');
if (!response.ok) {
throw new Error('Failed to list roles');
}
return response.json();
},
async get(id: string): Promise<Role> {
const response = await authFetch(`/roles/${id}`);
if (!response.ok) {
throw new Error('Failed to get role');
}
return response.json();
},
async create(data: CreateRoleRequest): Promise<Role> {
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<Role> {
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<void> {
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');
}
}
};

View File

@@ -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'}
</span>
</div>
<!-- User menu -->
<UserMenu />
</div>
<!-- Mobile controls -->
@@ -236,6 +240,11 @@
</span>
</div>
</div>
<!-- User menu (mobile) -->
<div class="mt-3 pt-3 border-t {$theme === 'light' ? 'border-black/10' : 'border-white/10'}">
<UserMenu />
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,238 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authApi } from '$lib/api/auth';
import { authStore, currentUser, isAuthenticated } from '$lib/stores/auth';
import { hasPermission } from '$lib/stores/auth';
let isOpen = false;
function toggleMenu() {
isOpen = !isOpen;
}
function closeMenu() {
isOpen = false;
}
async function handleLogout() {
closeMenu();
try {
await authApi.logout();
} finally {
authStore.logout();
goto('/login');
}
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.user-menu')) {
closeMenu();
}
}
</script>
<svelte:window onclick={handleClickOutside} />
{#if $isAuthenticated && $currentUser}
<div class="user-menu">
<button class="user-button" onclick={toggleMenu}>
<div class="avatar">
{$currentUser.username.charAt(0).toUpperCase()}
</div>
<span class="username">{$currentUser.username}</span>
<svg class="chevron" class:open={isOpen} viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
{#if isOpen}
<div class="dropdown">
<div class="dropdown-header">
<div class="user-info">
<span class="user-name">{$currentUser.username}</span>
{#if $currentUser.email}
<span class="user-email">{$currentUser.email}</span>
{/if}
<span class="user-roles">
{$currentUser.roles?.join(', ') || 'No roles'}
</span>
</div>
</div>
<div class="dropdown-divider"></div>
{#if hasPermission('users:view') || hasPermission('roles:view')}
<a href="/admin" class="dropdown-item" onclick={closeMenu}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clip-rule="evenodd"
/>
</svg>
Admin
</a>
{/if}
<button class="dropdown-item" onclick={handleLogout}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1V4a1 1 0 00-1-1H3zm11 4a1 1 0 10-2 0v4a1 1 0 102 0V7zm-3 1a1 1 0 10-2 0v3a1 1 0 102 0V8zM8 9a1 1 0 00-2 0v2a1 1 0 102 0V9z"
clip-rule="evenodd"
/>
</svg>
Sign Out
</button>
</div>
{/if}
</div>
{:else}
<a href="/login" class="login-link">Sign In</a>
{/if}
<style>
.user-menu {
position: relative;
}
.user-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9999px;
cursor: pointer;
color: inherit;
font-size: 0.875rem;
transition: background 0.2s;
}
.user-button:hover {
background: rgba(255, 255, 255, 0.15);
}
.avatar {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
color: white;
}
.username {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
width: 1rem;
height: 1rem;
opacity: 0.6;
transition: transform 0.2s;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
min-width: 200px;
background: rgba(30, 30, 40, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
overflow: hidden;
z-index: 100;
}
.dropdown-header {
padding: 0.75rem 1rem;
}
.user-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.user-name {
font-weight: 600;
font-size: 0.9rem;
}
.user-email {
font-size: 0.75rem;
opacity: 0.6;
}
.user-roles {
font-size: 0.7rem;
opacity: 0.5;
margin-top: 0.25rem;
}
.dropdown-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
color: inherit;
font-size: 0.875rem;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.dropdown-item svg {
width: 1.25rem;
height: 1.25rem;
opacity: 0.6;
}
.login-link {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border-radius: 0.5rem;
color: white;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.login-link:hover {
opacity: 0.9;
}
</style>

View File

@@ -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<AuthState>(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<string, Permission[]> = {
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<string, Permission[]> = {
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;
});
}

View File

@@ -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[];
}

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { isAuthenticated, hasAnyPermission } from '$lib/stores/auth';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
import { page } from '$app/stores';
let { children } = $props();
// Check auth on mount and when auth state changes
$effect(() => {
if (!$isAuthenticated) {
goto('/login');
} else if (!hasAnyPermission('users:view', 'roles:view')) {
goto('/');
}
});
const navItems = [
{ href: '/admin/users', label: 'Users', permission: 'users:view' as const },
{ href: '/admin/roles', label: 'Roles', permission: 'roles:view' as const }
];
</script>
{#if $isAuthenticated}
<div class="admin-layout {$theme === 'light' ? 'light' : 'dark'}">
<nav class="admin-nav">
<div class="nav-header">
<a href="/" class="back-link">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clip-rule="evenodd"
/>
</svg>
Back to Dashboard
</a>
<h1>Administration</h1>
</div>
<ul class="nav-links">
{#each navItems as item}
<li>
<a href={item.href} class:active={$page.url.pathname === item.href}>
{item.label}
</a>
</li>
{/each}
</ul>
</nav>
<main class="admin-content">
{@render children()}
</main>
</div>
{/if}
<style>
.admin-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-nav {
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
}
.nav-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1rem;
}
.back-link {
display: flex;
align-items: center;
gap: 0.5rem;
color: inherit;
opacity: 0.6;
text-decoration: none;
font-size: 0.875rem;
transition: opacity 0.2s;
}
.back-link:hover {
opacity: 1;
}
.back-link svg {
width: 1rem;
height: 1rem;
}
.nav-header h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.nav-links {
display: flex;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.nav-links a {
display: block;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
color: inherit;
text-decoration: none;
font-size: 0.9rem;
transition:
background 0.2s,
color 0.2s;
}
.nav-links a:hover {
background: rgba(128, 128, 128, 0.1);
}
.nav-links a.active {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.admin-content {
flex: 1;
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { hasPermission } from '$lib/stores/auth';
onMount(() => {
// Redirect to the first accessible admin page
if (hasPermission('users:view')) {
goto('/admin/users', { replaceState: true });
} else if (hasPermission('roles:view')) {
goto('/admin/roles', { replaceState: true });
}
});
</script>
<svelte:head>
<title>Admin - Tyto</title>
</svelte:head>
<div class="admin-home">
<h2>Administration</h2>
<p>Select a section from the navigation above.</p>
</div>
<style>
.admin-home {
text-align: center;
padding: 3rem 1rem;
}
.admin-home h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.admin-home p {
opacity: 0.6;
margin: 0;
}
</style>

View File

@@ -0,0 +1,845 @@
<script lang="ts">
import { onMount } from 'svelte';
import { rolesApi } from '$lib/api/auth';
import type { Role, Permission } from '$lib/types/auth';
import { hasPermission } from '$lib/stores/auth';
// All available permissions
const allPermissions: { category: string; permissions: { value: Permission; label: string }[] }[] = [
{
category: 'Dashboard',
permissions: [{ value: 'dashboard:view', label: 'View Dashboard' }]
},
{
category: 'Agents',
permissions: [
{ value: 'agents:view', label: 'View Agents' },
{ value: 'agents:manage', label: 'Manage Agents' }
]
},
{
category: 'Alerts',
permissions: [
{ value: 'alerts:view', label: 'View Alerts' },
{ value: 'alerts:acknowledge', label: 'Acknowledge Alerts' },
{ value: 'alerts:configure', label: 'Configure Alerts' }
]
},
{
category: 'Metrics',
permissions: [
{ value: 'metrics:query', label: 'Query Metrics' },
{ value: 'metrics:export', label: 'Export Metrics' }
]
},
{
category: 'Users',
permissions: [
{ value: 'users:view', label: 'View Users' },
{ value: 'users:manage', label: 'Manage Users' }
]
},
{
category: 'Roles',
permissions: [
{ value: 'roles:view', label: 'View Roles' },
{ value: 'roles:manage', label: 'Manage Roles' }
]
},
{
category: 'Settings',
permissions: [
{ value: 'settings:view', label: 'View Settings' },
{ value: 'settings:manage', label: 'Manage Settings' }
]
},
{
category: 'System',
permissions: [
{ value: 'audit:view', label: 'View Audit Log' },
{ value: 'pki:manage', label: 'Manage PKI' }
]
}
];
let roles: Role[] = [];
let isLoading = true;
let error = '';
// Modal state
let showCreateModal = false;
let showEditModal = false;
let showDeleteModal = false;
let editingRole: Role | null = null;
// Form state
let newRole = { name: '', description: '', permissions: [] as Permission[] };
let editForm = { name: '', description: '', permissions: [] as Permission[] };
let formError = '';
let isSubmitting = false;
onMount(async () => {
await loadRoles();
});
async function loadRoles() {
isLoading = true;
error = '';
try {
roles = await rolesApi.list();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load roles';
} finally {
isLoading = false;
}
}
function openCreateModal() {
newRole = { name: '', description: '', permissions: [] };
formError = '';
showCreateModal = true;
}
function openEditModal(role: Role) {
editingRole = role;
editForm = {
name: role.name,
description: role.description || '',
permissions: [...role.permissions]
};
formError = '';
showEditModal = true;
}
function openDeleteModal(role: Role) {
editingRole = role;
showDeleteModal = true;
}
function closeModals() {
showCreateModal = false;
showEditModal = false;
showDeleteModal = false;
editingRole = null;
formError = '';
}
async function handleCreate() {
if (!newRole.name) {
formError = 'Role name is required';
return;
}
isSubmitting = true;
formError = '';
try {
await rolesApi.create({
name: newRole.name,
description: newRole.description || undefined,
permissions: newRole.permissions
});
closeModals();
await loadRoles();
} catch (err) {
formError = err instanceof Error ? err.message : 'Failed to create role';
} finally {
isSubmitting = false;
}
}
async function handleEdit() {
if (!editingRole) return;
isSubmitting = true;
formError = '';
try {
await rolesApi.update(editingRole.id, {
name: editForm.name,
description: editForm.description || undefined,
permissions: editForm.permissions
});
closeModals();
await loadRoles();
} catch (err) {
formError = err instanceof Error ? err.message : 'Failed to update role';
} finally {
isSubmitting = false;
}
}
async function handleDelete() {
if (!editingRole) return;
isSubmitting = true;
try {
await rolesApi.delete(editingRole.id);
closeModals();
await loadRoles();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete role';
closeModals();
} finally {
isSubmitting = false;
}
}
function togglePermission(perm: Permission, target: 'new' | 'edit') {
if (target === 'new') {
if (newRole.permissions.includes(perm)) {
newRole.permissions = newRole.permissions.filter((p) => p !== perm);
} else {
newRole.permissions = [...newRole.permissions, perm];
}
} else {
if (editForm.permissions.includes(perm)) {
editForm.permissions = editForm.permissions.filter((p) => p !== perm);
} else {
editForm.permissions = [...editForm.permissions, perm];
}
}
}
function selectAllInCategory(category: string, target: 'new' | 'edit') {
const categoryPerms = allPermissions.find((c) => c.category === category)?.permissions || [];
const permValues = categoryPerms.map((p) => p.value);
if (target === 'new') {
const allSelected = permValues.every((p) => newRole.permissions.includes(p));
if (allSelected) {
newRole.permissions = newRole.permissions.filter((p) => !permValues.includes(p));
} else {
const toAdd = permValues.filter((p) => !newRole.permissions.includes(p));
newRole.permissions = [...newRole.permissions, ...toAdd];
}
} else {
const allSelected = permValues.every((p) => editForm.permissions.includes(p));
if (allSelected) {
editForm.permissions = editForm.permissions.filter((p) => !permValues.includes(p));
} else {
const toAdd = permValues.filter((p) => !editForm.permissions.includes(p));
editForm.permissions = [...editForm.permissions, ...toAdd];
}
}
}
</script>
<svelte:head>
<title>Roles - Admin - Tyto</title>
</svelte:head>
<div class="roles-page">
<header class="page-header">
<h2>Roles</h2>
{#if hasPermission('roles:manage')}
<button class="btn-primary" onclick={openCreateModal}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
Add Role
</button>
{/if}
</header>
{#if error}
<div class="error-banner">{error}</div>
{/if}
{#if isLoading}
<div class="loading">Loading roles...</div>
{:else}
<div class="roles-grid">
{#each roles as role}
<div class="role-card" class:system={role.isSystem}>
<div class="role-header">
<div class="role-info">
<h3>{role.name}</h3>
{#if role.isSystem}
<span class="system-badge">System</span>
{/if}
</div>
{#if hasPermission('roles:manage') && !role.isSystem}
<div class="role-actions">
<button class="btn-icon" title="Edit" onclick={() => openEditModal(role)}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
<button class="btn-icon danger" title="Delete" onclick={() => openDeleteModal(role)}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
{#if role.description}
<p class="role-description">{role.description}</p>
{/if}
<div class="permissions-list">
<h4>Permissions ({role.permissions.length})</h4>
{#if role.permissions.includes('*' as Permission)}
<span class="perm-badge admin">All Permissions</span>
{:else}
<div class="perm-badges">
{#each role.permissions.slice(0, 6) as perm}
<span class="perm-badge">{perm}</span>
{/each}
{#if role.permissions.length > 6}
<span class="perm-badge more">+{role.permissions.length - 6} more</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Role Modal -->
{#if showCreateModal}
<div class="modal-overlay" onclick={closeModals}>
<div class="modal modal-large" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Create Role</h3>
<button class="modal-close" onclick={closeModals}>&times;</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }}>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<div class="form-row">
<div class="form-group">
<label for="new-name">Role Name</label>
<input type="text" id="new-name" bind:value={newRole.name} required disabled={isSubmitting} placeholder="e.g., operator" />
</div>
<div class="form-group">
<label for="new-description">Description</label>
<input type="text" id="new-description" bind:value={newRole.description} disabled={isSubmitting} placeholder="Optional description" />
</div>
</div>
<div class="form-group">
<label>Permissions</label>
<div class="permissions-editor">
{#each allPermissions as category}
<div class="perm-category">
<button
type="button"
class="category-header"
onclick={() => selectAllInCategory(category.category, 'new')}
>
{category.category}
</button>
<div class="perm-options">
{#each category.permissions as perm}
<label class="checkbox-label">
<input
type="checkbox"
checked={newRole.permissions.includes(perm.value)}
onchange={() => togglePermission(perm.value, 'new')}
disabled={isSubmitting}
/>
{perm.label}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModals} disabled={isSubmitting}>Cancel</button>
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Role'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Role Modal -->
{#if showEditModal && editingRole}
<div class="modal-overlay" onclick={closeModals}>
<div class="modal modal-large" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Edit Role: {editingRole.name}</h3>
<button class="modal-close" onclick={closeModals}>&times;</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleEdit(); }}>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<div class="form-row">
<div class="form-group">
<label for="edit-name">Role Name</label>
<input type="text" id="edit-name" bind:value={editForm.name} required disabled={isSubmitting} />
</div>
<div class="form-group">
<label for="edit-description">Description</label>
<input type="text" id="edit-description" bind:value={editForm.description} disabled={isSubmitting} />
</div>
</div>
<div class="form-group">
<label>Permissions</label>
<div class="permissions-editor">
{#each allPermissions as category}
<div class="perm-category">
<button
type="button"
class="category-header"
onclick={() => selectAllInCategory(category.category, 'edit')}
>
{category.category}
</button>
<div class="perm-options">
{#each category.permissions as perm}
<label class="checkbox-label">
<input
type="checkbox"
checked={editForm.permissions.includes(perm.value)}
onchange={() => togglePermission(perm.value, 'edit')}
disabled={isSubmitting}
/>
{perm.label}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModals} disabled={isSubmitting}>Cancel</button>
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && editingRole}
<div class="modal-overlay" onclick={closeModals}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Delete Role</h3>
<button class="modal-close" onclick={closeModals}>&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the role <strong>{editingRole.name}</strong>?</p>
<p class="warning">This action cannot be undone. Users with this role will lose associated permissions.</p>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModals} disabled={isSubmitting}>Cancel</button>
<button type="button" class="btn-danger" onclick={handleDelete} disabled={isSubmitting}>
{isSubmitting ? 'Deleting...' : 'Delete Role'}
</button>
</div>
</div>
</div>
{/if}
<style>
.roles-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border: none;
border-radius: 0.5rem;
color: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary svg {
width: 1rem;
height: 1rem;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: rgba(128, 128, 128, 0.2);
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 0.5rem;
color: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(128, 128, 128, 0.3);
}
.btn-danger {
padding: 0.5rem 1rem;
background: #dc2626;
border: none;
border-radius: 0.5rem;
color: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-danger:hover:not(:disabled) {
opacity: 0.9;
}
.error-banner {
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.5rem;
color: #ef4444;
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 2rem;
opacity: 0.6;
}
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.role-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
padding: 1.25rem;
}
.role-card.system {
border-color: rgba(59, 130, 246, 0.3);
}
.role-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.role-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.role-info h3 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.system-badge {
padding: 0.125rem 0.5rem;
background: rgba(59, 130, 246, 0.2);
border-radius: 9999px;
font-size: 0.7rem;
color: #3b82f6;
}
.role-actions {
display: flex;
gap: 0.25rem;
}
.btn-icon {
padding: 0.375rem;
background: rgba(128, 128, 128, 0.1);
border: none;
border-radius: 0.375rem;
color: inherit;
cursor: pointer;
transition: background 0.2s;
}
.btn-icon:hover {
background: rgba(128, 128, 128, 0.2);
}
.btn-icon.danger:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.btn-icon svg {
width: 1rem;
height: 1rem;
display: block;
}
.role-description {
font-size: 0.875rem;
opacity: 0.6;
margin: 0 0 1rem 0;
}
.permissions-list h4 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
margin: 0 0 0.5rem 0;
}
.perm-badges {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.perm-badge {
padding: 0.125rem 0.5rem;
background: rgba(128, 128, 128, 0.2);
border-radius: 0.25rem;
font-size: 0.7rem;
}
.perm-badge.admin {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.perm-badge.more {
background: rgba(128, 128, 128, 0.1);
opacity: 0.6;
}
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: #1e1e28;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
}
.modal.modal-large {
max-width: 600px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header h3 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: inherit;
opacity: 0.6;
cursor: pointer;
line-height: 1;
}
.modal-close:hover {
opacity: 1;
}
.modal form,
.modal-body {
padding: 1.25rem;
}
.modal-body p {
margin: 0 0 0.5rem 0;
}
.modal-body .warning {
color: #ef4444;
font-size: 0.875rem;
}
.form-error {
padding: 0.75rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.5rem;
color: #ef4444;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
opacity: 0.9;
}
.form-group input[type='text'] {
width: 100%;
padding: 0.625rem 0.875rem;
background: rgba(128, 128, 128, 0.1);
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 0.5rem;
color: inherit;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.permissions-editor {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
}
.perm-category {
background: rgba(255, 255, 255, 0.03);
border-radius: 0.5rem;
padding: 0.75rem;
}
.category-header {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
color: inherit;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
margin-bottom: 0.5rem;
cursor: pointer;
padding: 0;
}
.category-header:hover {
opacity: 1;
}
.perm-options {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
cursor: pointer;
}
.checkbox-label input {
width: 0.875rem;
height: 0.875rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -0,0 +1,715 @@
<script lang="ts">
import { onMount } from 'svelte';
import { usersApi, rolesApi } from '$lib/api/auth';
import type { AdminUser, Role } from '$lib/types/auth';
import { hasPermission } from '$lib/stores/auth';
let users: AdminUser[] = [];
let roles: Role[] = [];
let isLoading = true;
let error = '';
// Modal state
let showCreateModal = false;
let showEditModal = false;
let showPasswordModal = false;
let editingUser: AdminUser | null = null;
// Form state
let newUser = { username: '', email: '', password: '', roles: [] as string[] };
let editForm = { email: '', roles: [] as string[] };
let newPassword = '';
let formError = '';
let isSubmitting = false;
onMount(async () => {
await loadData();
});
async function loadData() {
isLoading = true;
error = '';
try {
[users, roles] = await Promise.all([usersApi.list(), rolesApi.list()]);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load data';
} finally {
isLoading = false;
}
}
function openCreateModal() {
newUser = { username: '', email: '', password: '', roles: [] };
formError = '';
showCreateModal = true;
}
function openEditModal(user: AdminUser) {
editingUser = user;
editForm = { email: user.email || '', roles: [...user.roles] };
formError = '';
showEditModal = true;
}
function openPasswordModal(user: AdminUser) {
editingUser = user;
newPassword = '';
formError = '';
showPasswordModal = true;
}
function closeModals() {
showCreateModal = false;
showEditModal = false;
showPasswordModal = false;
editingUser = null;
formError = '';
}
async function handleCreate() {
if (!newUser.username || !newUser.password) {
formError = 'Username and password are required';
return;
}
isSubmitting = true;
formError = '';
try {
await usersApi.create({
username: newUser.username,
email: newUser.email || undefined,
password: newUser.password,
roles: newUser.roles
});
closeModals();
await loadData();
} catch (err) {
formError = err instanceof Error ? err.message : 'Failed to create user';
} finally {
isSubmitting = false;
}
}
async function handleEdit() {
if (!editingUser) return;
isSubmitting = true;
formError = '';
try {
await usersApi.update(editingUser.id, {
email: editForm.email || undefined,
roles: editForm.roles
});
closeModals();
await loadData();
} catch (err) {
formError = err instanceof Error ? err.message : 'Failed to update user';
} finally {
isSubmitting = false;
}
}
async function handlePasswordReset() {
if (!editingUser || !newPassword) {
formError = 'Password is required';
return;
}
isSubmitting = true;
formError = '';
try {
await usersApi.resetPassword(editingUser.id, newPassword);
closeModals();
} catch (err) {
formError = err instanceof Error ? err.message : 'Failed to reset password';
} finally {
isSubmitting = false;
}
}
async function toggleUserStatus(user: AdminUser) {
try {
if (user.disabled) {
await usersApi.enable(user.id);
} else {
await usersApi.disable(user.id);
}
await loadData();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update user status';
}
}
function toggleRole(roleId: string, target: 'new' | 'edit') {
if (target === 'new') {
if (newUser.roles.includes(roleId)) {
newUser.roles = newUser.roles.filter((r) => r !== roleId);
} else {
newUser.roles = [...newUser.roles, roleId];
}
} else {
if (editForm.roles.includes(roleId)) {
editForm.roles = editForm.roles.filter((r) => r !== roleId);
} else {
editForm.roles = [...editForm.roles, roleId];
}
}
}
function formatDate(dateStr: string | undefined): string {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString();
}
</script>
<svelte:head>
<title>Users - Admin - Tyto</title>
</svelte:head>
<div class="users-page">
<header class="page-header">
<h2>Users</h2>
{#if hasPermission('users:manage')}
<button class="btn-primary" onclick={openCreateModal}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
Add User
</button>
{/if}
</header>
{#if error}
<div class="error-banner">{error}</div>
{/if}
{#if isLoading}
<div class="loading">Loading users...</div>
{:else}
<div class="table-container">
<table class="users-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Roles</th>
<th>Status</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr class:disabled={user.disabled}>
<td class="username-cell">
<div class="avatar">
{user.username.charAt(0).toUpperCase()}
</div>
{user.username}
</td>
<td>{user.email || '-'}</td>
<td>
<div class="role-badges">
{#each user.roles as role}
<span class="role-badge">{role}</span>
{/each}
{#if user.roles.length === 0}
<span class="no-roles">No roles</span>
{/if}
</div>
</td>
<td>
<span class="status-badge" class:active={!user.disabled} class:inactive={user.disabled}>
{user.disabled ? 'Disabled' : 'Active'}
</span>
</td>
<td>{formatDate(user.lastLogin)}</td>
<td>
{#if hasPermission('users:manage')}
<div class="action-buttons">
<button class="btn-icon" title="Edit" onclick={() => openEditModal(user)}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
<button class="btn-icon" title="Reset Password" onclick={() => openPasswordModal(user)}>
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
</button>
<button
class="btn-icon"
class:danger={!user.disabled}
title={user.disabled ? 'Enable' : 'Disable'}
onclick={() => toggleUserStatus(user)}
>
{#if user.disabled}
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{:else}
<svg viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd" />
</svg>
{/if}
</button>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Create User Modal -->
{#if showCreateModal}
<div class="modal-overlay" onclick={closeModals}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Create User</h3>
<button class="modal-close" onclick={closeModals}>&times;</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }}>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<div class="form-group">
<label for="new-username">Username</label>
<input type="text" id="new-username" bind:value={newUser.username} required disabled={isSubmitting} />
</div>
<div class="form-group">
<label for="new-email">Email</label>
<input type="email" id="new-email" bind:value={newUser.email} disabled={isSubmitting} />
</div>
<div class="form-group">
<label for="new-password">Password</label>
<input type="password" id="new-password" bind:value={newUser.password} required disabled={isSubmitting} />
</div>
<div class="form-group">
<label>Roles</label>
<div class="role-checkboxes">
{#each roles as role}
<label class="checkbox-label">
<input
type="checkbox"
checked={newUser.roles.includes(role.name)}
onchange={() => toggleRole(role.name, 'new')}
disabled={isSubmitting}
/>
{role.name}
</label>
{/each}
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModals} disabled={isSubmitting}>Cancel</button>
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create User'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit User Modal -->
{#if showEditModal && editingUser}
<div class="modal-overlay" onclick={closeModals}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Edit User: {editingUser.username}</h3>
<button class="modal-close" onclick={closeModals}>&times;</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleEdit(); }}>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<div class="form-group">
<label for="edit-email">Email</label>
<input type="email" id="edit-email" bind:value={editForm.email} disabled={isSubmitting} />
</div>
<div class="form-group">
<label>Roles</label>
<div class="role-checkboxes">
{#each roles as role}
<label class="checkbox-label">
<input
type="checkbox"
checked={editForm.roles.includes(role.name)}
onchange={() => toggleRole(role.name, 'edit')}
disabled={isSubmitting}
/>
{role.name}
</label>
{/each}
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModals} disabled={isSubmitting}>Cancel</button>
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Password Reset Modal -->
{#if showPasswordModal && editingUser}
<div class="modal-overlay" onclick={closeModals}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Reset Password: {editingUser.username}</h3>
<button class="modal-close" onclick={closeModals}>&times;</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handlePasswordReset(); }}>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<div class="form-group">
<label for="new-password-reset">New Password</label>
<input type="password" id="new-password-reset" bind:value={newPassword} required disabled={isSubmitting} />
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModals} disabled={isSubmitting}>Cancel</button>
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.users-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border: none;
border-radius: 0.5rem;
color: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary svg {
width: 1rem;
height: 1rem;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: rgba(128, 128, 128, 0.2);
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 0.5rem;
color: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(128, 128, 128, 0.3);
}
.error-banner {
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.5rem;
color: #ef4444;
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 2rem;
opacity: 0.6;
}
.table-container {
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.users-table th,
.users-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
}
.users-table th {
font-weight: 600;
opacity: 0.7;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.users-table tr.disabled {
opacity: 0.5;
}
.username-cell {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
color: white;
}
.role-badges {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.role-badge {
padding: 0.125rem 0.5rem;
background: rgba(59, 130, 246, 0.2);
border-radius: 9999px;
font-size: 0.75rem;
color: #3b82f6;
}
.no-roles {
font-size: 0.75rem;
opacity: 0.5;
font-style: italic;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.active {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.status-badge.inactive {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.action-buttons {
display: flex;
gap: 0.25rem;
}
.btn-icon {
padding: 0.375rem;
background: rgba(128, 128, 128, 0.1);
border: none;
border-radius: 0.375rem;
color: inherit;
cursor: pointer;
transition: background 0.2s;
}
.btn-icon:hover {
background: rgba(128, 128, 128, 0.2);
}
.btn-icon.danger:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.btn-icon svg {
width: 1rem;
height: 1rem;
display: block;
}
/* Modal styles */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: #1e1e28;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header h3 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: inherit;
opacity: 0.6;
cursor: pointer;
line-height: 1;
}
.modal-close:hover {
opacity: 1;
}
.modal form {
padding: 1.25rem;
}
.form-error {
padding: 0.75rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.5rem;
color: #ef4444;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
opacity: 0.9;
}
.form-group input[type='text'],
.form-group input[type='email'],
.form-group input[type='password'] {
width: 100%;
padding: 0.625rem 0.875rem;
background: rgba(128, 128, 128, 0.1);
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 0.5rem;
color: inherit;
font-size: 0.875rem;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.role-checkboxes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.checkbox-label input {
width: 1rem;
height: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authApi } from '$lib/api/auth';
import { authStore, isAuthenticated } from '$lib/stores/auth';
import { theme } from '$lib/stores/theme';
let username = '';
let password = '';
let error = '';
let isLoading = false;
// Redirect if already logged in
$: if ($isAuthenticated) {
goto('/');
}
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
isLoading = true;
try {
const response = await authApi.login({ username, password });
authStore.setAuth(response);
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Login failed';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Login - Tyto</title>
</svelte:head>
<div class="login-container">
<div class="login-card {$theme === 'light' ? 'light' : 'dark'}">
<div class="login-header">
<span class="logo">🦉</span>
<h1>Tyto</h1>
<p class="subtitle">System Monitoring</p>
</div>
<form onsubmit={handleSubmit}>
{#if error}
<div class="error-message">
{error}
</div>
{/if}
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
bind:value={username}
placeholder="Enter username"
required
disabled={isLoading}
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
bind:value={password}
placeholder="Enter password"
required
disabled={isLoading}
autocomplete="current-password"
/>
</div>
<button type="submit" class="login-button" disabled={isLoading}>
{#if isLoading}
<span class="spinner"></span>
Signing in...
{:else}
Sign In
{/if}
</button>
</form>
</div>
</div>
<style>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.login-card.dark {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-card.light {
background: white;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
font-size: 3rem;
display: block;
margin-bottom: 0.5rem;
}
.login-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin: 0;
}
.subtitle {
margin: 0.25rem 0 0;
opacity: 0.6;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
opacity: 0.9;
}
.form-group input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border-radius: 0.5rem;
border: 1px solid rgba(128, 128, 128, 0.3);
background: rgba(128, 128, 128, 0.1);
color: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-button {
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
font-weight: 600;
border-radius: 0.5rem;
border: none;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.login-button:hover:not(:disabled) {
opacity: 0.9;
}
.login-button:active:not(:disabled) {
transform: scale(0.98);
}
.login-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
margin-bottom: 1.25rem;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>