Передаем на тест
This commit is contained in:
@@ -3,16 +3,28 @@
|
||||
import Navbar from '../components/Navbar.svelte';
|
||||
import Footer from '../components/Footer.svelte';
|
||||
import Toast from '../components/Toast.svelte';
|
||||
import ProtectedRoute from '../components/auth/ProtectedRoute.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
$: isLoginPage = $page.url.pathname === '/login';
|
||||
</script>
|
||||
|
||||
<Toast />
|
||||
|
||||
<main class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
{#if isLoginPage}
|
||||
<div class="p-4 flex-grow">
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
<ProtectedRoute>
|
||||
<Navbar />
|
||||
|
||||
<div class="p-4 flex-grow">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="p-4 flex-grow">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<Footer />
|
||||
</ProtectedRoute>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
216
frontend/src/routes/admin/roles/+page.svelte
Normal file
216
frontend/src/routes/admin/roles/+page.svelte
Normal file
@@ -0,0 +1,216 @@
|
||||
<!-- [DEF:AdminRolesPage:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: admin, role-management, rbac
|
||||
@PURPOSE: UI for managing system roles and their permissions.
|
||||
@LAYER: Feature
|
||||
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
|
||||
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
|
||||
|
||||
@INVARIANT: Only accessible by users with Admin role.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
|
||||
import { adminService } from '../../../services/adminService';
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
let roles = [];
|
||||
let permissions = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
let showModal = false;
|
||||
let isEditing = false;
|
||||
let currentRoleId = null;
|
||||
let roleForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
};
|
||||
|
||||
// [DEF:loadData:Function]
|
||||
/**
|
||||
* @purpose Fetches roles and available permissions.
|
||||
* @pre Component mounted.
|
||||
* @post roles and permissions arrays populated.
|
||||
*/
|
||||
async function loadData() {
|
||||
console.log('[AdminRolesPage][loadData][Entry]');
|
||||
loading = true;
|
||||
try {
|
||||
[roles, permissions] = await Promise.all([
|
||||
adminService.getRoles(),
|
||||
adminService.getPermissions()
|
||||
]);
|
||||
console.log('[AdminRolesPage][loadData][Coherence:OK]');
|
||||
} catch (e) {
|
||||
error = "Failed to load roles data.";
|
||||
console.error('[AdminRolesPage][loadData][Coherence:Failed]', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadData:Function]
|
||||
|
||||
// [DEF:openCreateModal:Function]
|
||||
function openCreateModal() {
|
||||
isEditing = false;
|
||||
currentRoleId = null;
|
||||
roleForm = { name: '', description: '', permissions: [] };
|
||||
showModal = true;
|
||||
}
|
||||
// [/DEF:openCreateModal:Function]
|
||||
|
||||
// [DEF:openEditModal:Function]
|
||||
function openEditModal(role) {
|
||||
isEditing = true;
|
||||
currentRoleId = role.id;
|
||||
roleForm = {
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
permissions: role.permissions.map(p => p.id)
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
// [/DEF:openEditModal:Function]
|
||||
|
||||
// [DEF:handleSaveRole:Function]
|
||||
/**
|
||||
* @purpose Submits role data (create or update).
|
||||
*/
|
||||
async function handleSaveRole() {
|
||||
console.log('[AdminRolesPage][handleSaveRole][Entry]');
|
||||
try {
|
||||
if (isEditing) {
|
||||
await adminService.updateRole(currentRoleId, roleForm);
|
||||
} else {
|
||||
await adminService.createRole(roleForm);
|
||||
}
|
||||
showModal = false;
|
||||
await loadData();
|
||||
console.log('[AdminRolesPage][handleSaveRole][Coherence:OK]');
|
||||
} catch (e) {
|
||||
alert("Failed to save role: " + e.message);
|
||||
console.error('[AdminRolesPage][handleSaveRole][Coherence:Failed]', e);
|
||||
}
|
||||
}
|
||||
// [/DEF:handleSaveRole:Function]
|
||||
|
||||
// [DEF:handleDeleteRole:Function]
|
||||
async function handleDeleteRole(role) {
|
||||
if (!confirm($t.admin.roles.confirm_delete.replace('{name}', role.name))) return;
|
||||
|
||||
console.log('[AdminRolesPage][handleDeleteRole][Entry]');
|
||||
try {
|
||||
await adminService.deleteRole(role.id);
|
||||
await loadData();
|
||||
console.log('[AdminRolesPage][handleDeleteRole][Coherence:OK]');
|
||||
} catch (e) {
|
||||
alert("Failed to delete role: " + e.message);
|
||||
console.error('[AdminRolesPage][handleDeleteRole][Coherence:Failed]', e);
|
||||
}
|
||||
}
|
||||
// [/DEF:handleDeleteRole:Function]
|
||||
|
||||
onMount(loadData);
|
||||
</script>
|
||||
|
||||
<ProtectedRoute requiredPermission="admin:roles">
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{$t.admin.roles.title}</h1>
|
||||
<button
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
on:click={openCreateModal}
|
||||
>
|
||||
{$t.admin.roles.create}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p>{$t.admin.roles.loading}</p>
|
||||
{:else if error}
|
||||
<div class="bg-red-100 text-red-700 p-4 rounded">{error}</div>
|
||||
{:else}
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.name}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.description}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.permissions}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each roles as role}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap font-medium">{role.name}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{role.description || '-'}</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each role.permissions as perm}
|
||||
<span class="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded border border-blue-100">
|
||||
{perm.resource}:{perm.action}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button on:click={() => openEditModal(role)} class="text-blue-600 hover:text-blue-900 mr-3">{$t.common.edit}</button>
|
||||
<button on:click={() => handleDeleteRole(role)} class="text-red-600 hover:text-red-900">{$t.common.delete}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showModal}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-bold mb-4">
|
||||
{isEditing ? $t.admin.roles.modal_edit_title : $t.admin.roles.modal_create_title}
|
||||
</h2>
|
||||
<form on:submit|preventDefault={handleSaveRole}>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1">{$t.admin.roles.name}</label>
|
||||
<input type="text" bind:value={roleForm.name} class="w-full border p-2 rounded" required readonly={isEditing} />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1">{$t.admin.roles.description}</label>
|
||||
<textarea bind:value={roleForm.description} class="w-full border p-2 rounded h-20"></textarea>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">{$t.admin.roles.permissions}</label>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 border p-3 rounded bg-gray-50">
|
||||
{#each permissions as perm}
|
||||
<label class="flex items-center space-x-2 p-1 hover:bg-white rounded cursor-pointer">
|
||||
<input type="checkbox" value={perm.id} bind:group={roleForm.permissions} class="rounded text-blue-600" />
|
||||
<span class="text-xs">{perm.resource}:{perm.action}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">{$t.admin.roles.permissions_hint}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-4 border-t">
|
||||
<button type="button" class="px-4 py-2 text-gray-600" on:click={() => showModal = false}>{$t.common.cancel}</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">{$t.common.save}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:AdminRolesPage:Component] -->
|
||||
213
frontend/src/routes/admin/settings/+page.svelte
Normal file
213
frontend/src/routes/admin/settings/+page.svelte
Normal file
@@ -0,0 +1,213 @@
|
||||
<!-- [DEF:AdminSettingsPage:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: admin, adfs, mappings, configuration
|
||||
@PURPOSE: UI for configuring Active Directory Group to local Role mappings for ADFS SSO.
|
||||
@LAYER: Feature
|
||||
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
|
||||
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
|
||||
|
||||
@INVARIANT: Only accessible by users with "admin:settings" permission.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
|
||||
import { adminService } from '../../../services/adminService';
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
let mappings = [];
|
||||
let roles = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
let showCreateModal = false;
|
||||
let newMapping = {
|
||||
ad_group: '',
|
||||
role_id: ''
|
||||
};
|
||||
|
||||
// [DEF:loadData:Function]
|
||||
/**
|
||||
* @purpose Fetches AD mappings and roles from the backend to populate the UI.
|
||||
* @pre Component is mounted and user has active session.
|
||||
* @post mappings and roles variables are updated with backend data.
|
||||
* @returns {Promise<void>}
|
||||
* @side_effect Updates local 'mappings', 'roles', 'loading', and 'error' states.
|
||||
* @relation CALLS -> adminService.getRoles
|
||||
* @relation CALLS -> adminService.getADGroupMappings
|
||||
*/
|
||||
async function loadData() {
|
||||
console.log('[AdminSettingsPage][loadData][Entry]');
|
||||
loading = true;
|
||||
try {
|
||||
// Fetch roles first as they are required for displaying mapping labels
|
||||
roles = await adminService.getRoles();
|
||||
|
||||
try {
|
||||
mappings = await adminService.getADGroupMappings();
|
||||
} catch (e) {
|
||||
console.warn("[AdminSettingsPage][loadData] AD Mappings endpoint potentially unavailable.");
|
||||
}
|
||||
|
||||
console.log('[AdminSettingsPage][loadData][Coherence:OK]');
|
||||
} catch (e) {
|
||||
error = "Failed to load roles or configuration.";
|
||||
console.error('[AdminSettingsPage][loadData][Coherence:Failed]', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadData:Function]
|
||||
|
||||
// [DEF:handleCreateMapping:Function]
|
||||
/**
|
||||
* @purpose Submits a new AD Group to Role mapping to the backend.
|
||||
* @pre 'newMapping' object contains valid 'ad_group' and 'role_id'.
|
||||
* @post A new mapping is created in the database and the table is refreshed.
|
||||
* @returns {Promise<void>}
|
||||
* @side_effect Closes the modal on success, shows alert on failure.
|
||||
* @relation CALLS -> adminService.createADGroupMapping
|
||||
*/
|
||||
async function handleCreateMapping() {
|
||||
console.log('[AdminSettingsPage][handleCreateMapping][Entry]');
|
||||
|
||||
// Guard Clause (@PRE)
|
||||
if (!newMapping.ad_group || !newMapping.role_id) {
|
||||
alert("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adminService.createADGroupMapping(newMapping);
|
||||
showCreateModal = false;
|
||||
// Reset form
|
||||
newMapping = { ad_group: '', role_id: '' };
|
||||
await loadData();
|
||||
console.log('[AdminSettingsPage][handleCreateMapping][Coherence:OK]');
|
||||
} catch (e) {
|
||||
alert("Failed to create mapping: " + (e.message || "Unknown error"));
|
||||
console.error('[AdminSettingsPage][handleCreateMapping][Coherence:Failed]', e);
|
||||
}
|
||||
}
|
||||
// [/DEF:handleCreateMapping:Function]
|
||||
|
||||
onMount(loadData);
|
||||
</script>
|
||||
|
||||
<ProtectedRoute requiredPermission="admin:settings">
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{$t.admin.settings.title}</h1>
|
||||
<button
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
|
||||
on:click={() => showCreateModal = true}
|
||||
>
|
||||
{$t.admin.settings.add_mapping}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-8">
|
||||
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4" role="alert">
|
||||
<p class="font-bold">{$t.common.error}</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.ad_group}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.local_role}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each mappings as mapping}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm text-gray-600">{mapping.ad_group}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
|
||||
{roles.find(r => r.id === mapping.role_id)?.name || mapping.role_id}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if mappings.length === 0}
|
||||
<tr>
|
||||
<td colspan="2" class="px-6 py-12 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<p>{$t.admin.settings.no_mappings}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCreateModal}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
|
||||
<h2 class="text-xl font-bold mb-4 border-b pb-2">{$t.admin.settings.modal_title}</h2>
|
||||
<form on:submit|preventDefault={handleCreateMapping}>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.ad_group_dn}</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newMapping.ad_group}
|
||||
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="e.g. CN=SS_ADMINS,OU=Groups,DC=org"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">{$t.admin.settings.ad_group_hint}</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.local_role_select}</label>
|
||||
<select
|
||||
bind:value={newMapping.role_id}
|
||||
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="" disabled>{$t.admin.settings.select_role}</option>
|
||||
{#each roles as role}
|
||||
<option value={role.id}>{role.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium"
|
||||
on:click={() => showCreateModal = false}
|
||||
>
|
||||
{$t.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md"
|
||||
>
|
||||
{$t.common.save}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:AdminSettingsPage:Component] -->
|
||||
273
frontend/src/routes/admin/users/+page.svelte
Normal file
273
frontend/src/routes/admin/users/+page.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<!-- [DEF:AdminUsersPage:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: admin, user-management, rbac
|
||||
@PURPOSE: UI for managing system users and their roles.
|
||||
@LAYER: Feature
|
||||
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
|
||||
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
|
||||
|
||||
@INVARIANT: Only accessible by users with "admin:users" permission.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
|
||||
import { adminService } from '../../../services/adminService';
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
let users = [];
|
||||
let roles = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
let showModal = false;
|
||||
let isEditing = false;
|
||||
let currentUserId = null;
|
||||
let userForm = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_active: true
|
||||
};
|
||||
|
||||
// [DEF:loadData:Function]
|
||||
/**
|
||||
* @purpose Fetches users and roles from the backend.
|
||||
* @pre Component mounted.
|
||||
* @post users and roles arrays populated.
|
||||
*/
|
||||
async function loadData() {
|
||||
console.log('[AdminUsersPage][loadData][Entry]');
|
||||
loading = true;
|
||||
try {
|
||||
[users, roles] = await Promise.all([
|
||||
adminService.getUsers(),
|
||||
adminService.getRoles()
|
||||
]);
|
||||
console.log('[AdminUsersPage][loadData][Coherence:OK]');
|
||||
} catch (e) {
|
||||
error = "Failed to load admin data.";
|
||||
console.error('[AdminUsersPage][loadData][Coherence:Failed]', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:loadData:Function]
|
||||
|
||||
// [DEF:openCreateModal:Function]
|
||||
/**
|
||||
* @purpose Prepares the form for creating a new user.
|
||||
* @post showModal is true, isEditing is false, userForm is reset.
|
||||
*/
|
||||
function openCreateModal() {
|
||||
isEditing = false;
|
||||
currentUserId = null;
|
||||
userForm = { username: '', email: '', password: '', roles: [], is_active: true };
|
||||
showModal = true;
|
||||
}
|
||||
// [/DEF:openCreateModal:Function]
|
||||
|
||||
// [DEF:openEditModal:Function]
|
||||
/**
|
||||
* @purpose Prepares the form for editing an existing user.
|
||||
* @pre user object must be valid.
|
||||
* @post showModal is true, isEditing is true, userForm populated with user data.
|
||||
* @param {Object} user - The user object to edit.
|
||||
*/
|
||||
function openEditModal(user) {
|
||||
isEditing = true;
|
||||
currentUserId = user.id;
|
||||
userForm = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
roles: user.roles.map(r => r.name),
|
||||
is_active: user.is_active
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
// [/DEF:openEditModal:Function]
|
||||
|
||||
// [DEF:handleSaveUser:Function]
|
||||
/**
|
||||
* @purpose Submits user data to the backend (create or update).
|
||||
* @pre userForm must be valid.
|
||||
* @post User created or updated, modal closed, data reloaded.
|
||||
* @side_effect Triggers API call to adminService.
|
||||
* @relation CALLS -> adminService.createUser
|
||||
* @relation CALLS -> adminService.updateUser
|
||||
*/
|
||||
async function handleSaveUser() {
|
||||
console.log('[AdminUsersPage][handleSaveUser][Entry]');
|
||||
try {
|
||||
if (isEditing) {
|
||||
const updateData = { ...userForm };
|
||||
if (!updateData.password) delete updateData.password;
|
||||
await adminService.updateUser(currentUserId, updateData);
|
||||
} else {
|
||||
await adminService.createUser(userForm);
|
||||
}
|
||||
showModal = false;
|
||||
await loadData();
|
||||
console.log('[AdminUsersPage][handleSaveUser][Coherence:OK]');
|
||||
} catch (e) {
|
||||
alert("Failed to save user: " + e.message);
|
||||
console.error('[AdminUsersPage][handleSaveUser][Coherence:Failed]', e);
|
||||
}
|
||||
}
|
||||
// [/DEF:handleSaveUser:Function]
|
||||
|
||||
// [DEF:handleDeleteUser:Function]
|
||||
/**
|
||||
* @purpose Deletes a user after confirmation.
|
||||
* @pre user object must be valid.
|
||||
* @post User deleted if confirmed, data reloaded.
|
||||
* @side_effect Triggers API call to adminService.
|
||||
* @relation CALLS -> adminService.deleteUser
|
||||
* @param {Object} user - The user to delete.
|
||||
*/
|
||||
async function handleDeleteUser(user) {
|
||||
if (!confirm($t.admin.users.confirm_delete.replace('{username}', user.username))) return;
|
||||
|
||||
console.log('[AdminUsersPage][handleDeleteUser][Entry]');
|
||||
try {
|
||||
await adminService.deleteUser(user.id);
|
||||
await loadData();
|
||||
console.log('[AdminUsersPage][handleDeleteUser][Coherence:OK]');
|
||||
} catch (e) {
|
||||
alert("Failed to delete user: " + e.message);
|
||||
console.error('[AdminUsersPage][handleDeleteUser][Coherence:Failed]', e);
|
||||
}
|
||||
}
|
||||
// [/DEF:handleDeleteUser:Function]
|
||||
|
||||
onMount(loadData);
|
||||
</script>
|
||||
|
||||
<ProtectedRoute requiredPermission="admin:users">
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{$t.admin.users.title}</h1>
|
||||
<button
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
|
||||
on:click={openCreateModal}
|
||||
>
|
||||
{$t.admin.users.create}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-8">
|
||||
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4">
|
||||
<p class="font-bold">{$t.common.error}</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.username}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.email}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.source}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.roles}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.status}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{#each users as user}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap font-medium">{user.username}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.email || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full {user.auth_source === 'LOCAL' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'}">
|
||||
{user.auth_source}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each user.roles as role}
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded border border-gray-200">{role.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="flex items-center">
|
||||
<span class="h-2 w-2 rounded-full mr-2 {user.is_active ? 'bg-green-500' : 'bg-red-500'}"></span>
|
||||
<span class="text-sm {user.is_active ? 'text-green-700' : 'text-red-700'}">
|
||||
{user.is_active ? $t.admin.users.active : $t.admin.users.inactive}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button on:click={() => openEditModal(user)} class="text-blue-600 hover:text-blue-900 mr-3">{$t.common.edit}</button>
|
||||
<button on:click={() => handleDeleteUser(user)} class="text-red-600 hover:text-red-900">{$t.common.delete}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showModal}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
|
||||
<h2 class="text-xl font-bold mb-4 border-b pb-2">
|
||||
{isEditing ? $t.admin.users.modal_edit_title : $t.admin.users.modal_title}
|
||||
</h2>
|
||||
<form on:submit|preventDefault={handleSaveUser}>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.username}</label>
|
||||
<input type="text" bind:value={userForm.username} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50" required readonly={isEditing} />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.email}</label>
|
||||
<input type="email" bind:value={userForm.email} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.password}</label>
|
||||
<input type="password" bind:value={userForm.password} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required={!isEditing} />
|
||||
{#if isEditing}
|
||||
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.password_hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={userForm.is_active} class="rounded text-blue-600 focus:ring-blue-500" />
|
||||
<span class="text-sm font-medium text-gray-700">{$t.admin.users.active}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.roles}</label>
|
||||
<select multiple bind:value={userForm.roles} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-32">
|
||||
{#each roles as role}
|
||||
<option value={role.name}>{role.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.roles_hint}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium" on:click={() => showModal = false}>{$t.common.cancel}</button>
|
||||
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md transition-colors">{$t.common.save}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:AdminUsersPage:Component] -->
|
||||
165
frontend/src/routes/login/+page.svelte
Normal file
165
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<!-- [DEF:LoginPage:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: login, auth, ui, form
|
||||
@PURPOSE: Provides the user interface for local and ADFS authentication.
|
||||
@LAYER: Feature
|
||||
@RELATION: USES -> authStore
|
||||
@RELATION: CALLS -> api.auth.login
|
||||
|
||||
@INVARIANT: Shows both local login form and ADFS SSO button.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '../../lib/auth/store';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let loading = false;
|
||||
|
||||
// [DEF:handleLogin:Function]
|
||||
/**
|
||||
* @purpose Submits the local login form to the backend.
|
||||
* @pre Username and password are not empty.
|
||||
* @post User is authenticated and redirected on success.
|
||||
*/
|
||||
async function handleLogin() {
|
||||
if (!username || !password) {
|
||||
error = 'Please enter both username and password';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
auth.setToken(data.access_token);
|
||||
|
||||
// Fetch user profile
|
||||
const profileRes = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${data.access_token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (profileRes.ok) {
|
||||
const user = await profileRes.json();
|
||||
auth.setUser(user);
|
||||
goto('/');
|
||||
} else {
|
||||
error = 'Failed to fetch user profile';
|
||||
}
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
error = errData.detail || 'Invalid username or password';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'An error occurred during login';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:handleLogin:Function]
|
||||
|
||||
// [DEF:handleADFSLogin:Function]
|
||||
/**
|
||||
* @purpose Redirects the user to the ADFS login endpoint.
|
||||
*/
|
||||
function handleADFSLogin() {
|
||||
window.location.href = '/api/auth/login/adfs';
|
||||
}
|
||||
// [/DEF:handleADFSLogin:Function]
|
||||
|
||||
onMount(() => {
|
||||
if ($auth.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 class="text-2xl font-bold mb-6 text-center">Login</h2>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 p-3 bg-red-100 text-red-700 rounded border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleLogin} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
bind:value={username}
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button
|
||||
on:click={handleADFSLogin}
|
||||
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Corporate SSO (ADFS)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<style>
|
||||
/* No additional styles needed, using Tailwind */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:LoginPage:Component] -->
|
||||
@@ -8,9 +8,29 @@
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
|
||||
let settings = data.settings;
|
||||
let settings = data.settings || {
|
||||
environments: [],
|
||||
settings: {
|
||||
storage: {
|
||||
root_path: '',
|
||||
backup_structure_pattern: '',
|
||||
repo_structure_pattern: '',
|
||||
filename_pattern: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$: settings = data.settings;
|
||||
$: if (data.settings) {
|
||||
settings = { ...data.settings };
|
||||
if (settings.settings && !settings.settings.storage) {
|
||||
settings.settings.storage = {
|
||||
root_path: '',
|
||||
backup_structure_pattern: '',
|
||||
repo_structure_pattern: '',
|
||||
filename_pattern: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let newEnv = {
|
||||
id: '',
|
||||
|
||||
@@ -18,7 +18,13 @@ export async function load() {
|
||||
settings: {
|
||||
environments: [],
|
||||
settings: {
|
||||
default_environment_id: null
|
||||
default_environment_id: null,
|
||||
storage: {
|
||||
root_path: '',
|
||||
backup_structure_pattern: '',
|
||||
repo_structure_pattern: '',
|
||||
filename_pattern: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
error: 'Failed to load settings'
|
||||
|
||||
Reference in New Issue
Block a user