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:
271
frontend/src/lib/api/auth.ts
Normal file
271
frontend/src/lib/api/auth.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
238
frontend/src/lib/components/auth/UserMenu.svelte
Normal file
238
frontend/src/lib/components/auth/UserMenu.svelte
Normal 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>
|
||||
209
frontend/src/lib/stores/auth.ts
Normal file
209
frontend/src/lib/stores/auth.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
105
frontend/src/lib/types/auth.ts
Normal file
105
frontend/src/lib/types/auth.ts
Normal 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[];
|
||||
}
|
||||
140
frontend/src/routes/admin/+layout.svelte
Normal file
140
frontend/src/routes/admin/+layout.svelte
Normal 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>
|
||||
41
frontend/src/routes/admin/+page.svelte
Normal file
41
frontend/src/routes/admin/+page.svelte
Normal 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>
|
||||
845
frontend/src/routes/admin/roles/+page.svelte
Normal file
845
frontend/src/routes/admin/roles/+page.svelte
Normal 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}>×</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}>×</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}>×</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>
|
||||
715
frontend/src/routes/admin/users/+page.svelte
Normal file
715
frontend/src/routes/admin/users/+page.svelte
Normal 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}>×</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}>×</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}>×</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>
|
||||
229
frontend/src/routes/login/+page.svelte
Normal file
229
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user