Compare commits
10 Commits
47a72c82f4
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a42e995fd0 | ||
|
|
8442aaff8a | ||
|
|
0472c5e90b | ||
|
|
8ccb8033d1 | ||
|
|
7ae883411b | ||
|
|
995b3e6a63 | ||
|
|
31bed1e6cb | ||
|
|
a154a4953e | ||
|
|
283de4f27c | ||
|
|
53c023ca4d |
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "acc-server-manager-web",
|
"name": "acc-server-manager-web",
|
||||||
"version": "0.0.1",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "acc-server-manager-web",
|
"name": "acc-server-manager-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ async function fetchAPI(endpoint: string, method: string = 'GET', body?: object,
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (endpoint != '/api' && response.status == 401) {
|
if (response.status == 401) {
|
||||||
authStore.set({
|
authStore.set({
|
||||||
username: undefined,
|
username: undefined,
|
||||||
password: undefined,
|
password: undefined,
|
||||||
@@ -40,11 +40,15 @@ export async function fetchAPIEvent(
|
|||||||
method: string = 'GET',
|
method: string = 'GET',
|
||||||
body?: object
|
body?: object
|
||||||
) {
|
) {
|
||||||
const {
|
if (!event.cookies) {
|
||||||
data: { token }
|
redirect(308, '/login');
|
||||||
} = await redisSessionManager.getSession(event.cookies);
|
}
|
||||||
|
const session = await redisSessionManager.getSession(event.cookies);
|
||||||
|
if (!session?.data?.token) {
|
||||||
|
redirect(308, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
return fetchAPI(endpoint, method, body, { Authorization: `Basic ${token}` });
|
return fetchAPI(endpoint, method, body, { Authorization: `Bearer ${session.data.token}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default fetchAPI;
|
export default fetchAPI;
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
import fetchAPI, { fetchAPIEvent } from '$api/apiService';
|
import { fetchAPIEvent } from '$api/apiService';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import { authStore } from '$stores/authStore';
|
import { authStore } from '$stores/authStore';
|
||||||
import { redisSessionManager } from '$stores/redisSessionManager';
|
import { redisSessionManager } from '$stores/redisSessionManager';
|
||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export const login = async (event: RequestEvent, username: string, password: string) => {
|
export const login = async (event: RequestEvent, username: string, password: string) => {
|
||||||
const token = btoa(`${username}:${password}`);
|
try {
|
||||||
await redisSessionManager.createSession(event.cookies, { token }, uuidv4());
|
const response = await fetch(`${env.API_BASE_URL}/auth/login`, {
|
||||||
if (!(await checkAuth(event))) {
|
method: 'POST',
|
||||||
{
|
body: JSON.stringify({ username, password }),
|
||||||
authStore.set({ token: undefined, error: 'Invalid username or password.' });
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: 'Invalid username or password.' }));
|
||||||
|
authStore.set({
|
||||||
|
token: undefined,
|
||||||
|
error: errorData.error || 'Invalid username or password.'
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { token } = await response.json();
|
||||||
|
|
||||||
|
await redisSessionManager.createSession(event.cookies, { token }, uuidv4());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
authStore.set({ token: undefined, error: 'Login failed. Please try again.' });
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logout = (event: RequestEvent) => {
|
export const logout = (event: RequestEvent) => {
|
||||||
|
|||||||
185
src/api/membershipService.ts
Normal file
185
src/api/membershipService.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { fetchAPIEvent } from '$api/apiService';
|
||||||
|
import type { User } from '$models/user';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export interface MembershipFilter {
|
||||||
|
username?: string;
|
||||||
|
role_name?: string;
|
||||||
|
role_id?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_desc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedUsers {
|
||||||
|
users: User[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
roleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const membershipService = {
|
||||||
|
async getUsers(event: RequestEvent, filter?: MembershipFilter): Promise<User[]> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.username) queryParams.append('username', filter.username);
|
||||||
|
if (filter.role_name) queryParams.append('role_name', filter.role_name);
|
||||||
|
if (filter.role_id) queryParams.append('role_id', filter.role_id);
|
||||||
|
if (filter.page) queryParams.append('page', filter.page.toString());
|
||||||
|
if (filter.page_size) queryParams.append('page_size', filter.page_size.toString());
|
||||||
|
if (filter.sort_by) queryParams.append('sort_by', filter.sort_by);
|
||||||
|
if (filter.sort_desc !== undefined)
|
||||||
|
queryParams.append('sort_desc', filter.sort_desc.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `/membership${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||||
|
return await fetchAPIEvent(event, endpoint);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUsersPaginated(event: RequestEvent, filter?: MembershipFilter): Promise<PaginatedUsers> {
|
||||||
|
const users = await this.getUsers(event, filter);
|
||||||
|
|
||||||
|
const page = filter?.page || 1;
|
||||||
|
const pageSize = filter?.page_size || 10;
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
|
||||||
|
const paginatedUsers = users.slice(startIndex, endIndex);
|
||||||
|
const totalPages = Math.ceil(users.length / pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: paginatedUsers,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total: users.length,
|
||||||
|
total_pages: totalPages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUser(event: RequestEvent, userId: string): Promise<User> {
|
||||||
|
return await fetchAPIEvent(event, `/membership/${userId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createUser(event: RequestEvent, userData: CreateUserRequest): Promise<User> {
|
||||||
|
return await fetchAPIEvent(event, '/membership', 'POST', userData);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUser(
|
||||||
|
event: RequestEvent,
|
||||||
|
userId: string,
|
||||||
|
userData: UpdateUserRequest
|
||||||
|
): Promise<User> {
|
||||||
|
return await fetchAPIEvent(event, `/membership/${userId}`, 'PUT', userData);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUser(event: RequestEvent, userId: string): Promise<void> {
|
||||||
|
await fetchAPIEvent(event, `/membership/${userId}`, 'DELETE');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRoles(event: RequestEvent): Promise<Role[]> {
|
||||||
|
return await fetchAPIEvent(event, '/membership/roles');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client-side service for browser usage (not currently used)
|
||||||
|
export const membershipClientService = {
|
||||||
|
async getUsers(filter?: MembershipFilter): Promise<User[]> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
if (filter.username) queryParams.append('username', filter.username);
|
||||||
|
if (filter.role_name) queryParams.append('role_name', filter.role_name);
|
||||||
|
if (filter.role_id) queryParams.append('role_id', filter.role_id);
|
||||||
|
if (filter.page) queryParams.append('page', filter.page.toString());
|
||||||
|
if (filter.page_size) queryParams.append('page_size', filter.page_size.toString());
|
||||||
|
if (filter.sort_by) queryParams.append('sort_by', filter.sort_by);
|
||||||
|
if (filter.sort_desc !== undefined)
|
||||||
|
queryParams.append('sort_desc', filter.sort_desc.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `/membership${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch users: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createUser(userData: CreateUserRequest): Promise<User> {
|
||||||
|
const response = await fetch('/membership', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to create user: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUser(userId: string, userData: UpdateUserRequest): Promise<User> {
|
||||||
|
const response = await fetch(`/membership/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to update user: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUser(userId: string): Promise<void> {
|
||||||
|
const response = await fetch(`/membership/${userId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete user: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRoles(): Promise<Role[]> {
|
||||||
|
const response = await fetch('/membership/roles');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch roles: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -110,18 +110,18 @@ export const updateConfig = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restartService = async (event: RequestEvent, serverId: number) => {
|
export const restartService = async (event: RequestEvent, serverId: string) => {
|
||||||
return fetchAPIEvent(event, '/api/restart', 'POST', { serverId });
|
return fetchAPIEvent(event, `/server/${serverId}/service/restart`, 'POST');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startService = async (event: RequestEvent, serverId: number) => {
|
export const startService = async (event: RequestEvent, serverId: string) => {
|
||||||
return fetchAPIEvent(event, '/api/start', 'POST', { serverId });
|
return fetchAPIEvent(event, `/server/${serverId}/service/start`, 'POST');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stopService = async (event: RequestEvent, serverId: number) => {
|
export const stopService = async (event: RequestEvent, serverId: string) => {
|
||||||
return fetchAPIEvent(event, '/api/stop', 'POST', { serverId });
|
return fetchAPIEvent(event, `/server/${serverId}/service/stop`, 'POST');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServiceStatus = async (event: RequestEvent, serviceName: string) => {
|
export const getServiceStatus = async (event: RequestEvent, serverId: string) => {
|
||||||
return fetchAPIEvent(event, `/api/${serviceName}`);
|
return fetchAPIEvent(event, `/server/${serverId}/service`);
|
||||||
};
|
};
|
||||||
|
|||||||
4
src/app.d.ts
vendored
4
src/app.d.ts
vendored
@@ -3,7 +3,9 @@
|
|||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
interface Locals {
|
||||||
|
user: import('$models/user').User | null;
|
||||||
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|||||||
95
src/components/Pagination.svelte
Normal file
95
src/components/Pagination.svelte
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let currentPage: number = 1;
|
||||||
|
export let totalPages: number = 1;
|
||||||
|
export let onPageChange: (page: number) => void = () => {};
|
||||||
|
|
||||||
|
$: pages = generatePageNumbers(currentPage, totalPages);
|
||||||
|
|
||||||
|
function generatePageNumbers(current: number, total: number): (number | string)[] {
|
||||||
|
if (total <= 7) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
|
||||||
|
if (current <= 4) {
|
||||||
|
// Show first 5 pages, then ellipsis, then last page
|
||||||
|
pages.push(...[1, 2, 3, 4, 5]);
|
||||||
|
if (total > 6) pages.push('...');
|
||||||
|
pages.push(total);
|
||||||
|
} else if (current >= total - 3) {
|
||||||
|
// Show first page, ellipsis, then last 5 pages
|
||||||
|
pages.push(1);
|
||||||
|
if (total > 6) pages.push('...');
|
||||||
|
pages.push(...[total - 4, total - 3, total - 2, total - 1, total]);
|
||||||
|
} else {
|
||||||
|
// Show first page, ellipsis, current and neighbors, ellipsis, last page
|
||||||
|
pages.push(1, '...', current - 1, current, current + 1, '...', total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageClick(page: number | string) {
|
||||||
|
if (typeof page === 'number' && page !== currentPage) {
|
||||||
|
onPageChange(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrevious() {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
onPageChange(currentPage - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
onPageChange(currentPage + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center space-x-1">
|
||||||
|
<!-- Previous button -->
|
||||||
|
<button
|
||||||
|
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === 1
|
||||||
|
? 'cursor-not-allowed bg-gray-700 text-gray-500'
|
||||||
|
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onclick={handlePrevious}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page numbers -->
|
||||||
|
{#each pages as page}
|
||||||
|
{#if page === '...'}
|
||||||
|
<span class="px-3 py-2 text-sm text-gray-500">...</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {page === currentPage
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||||
|
onclick={() => handlePageClick(page)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Next button -->
|
||||||
|
<button
|
||||||
|
class="rounded-md px-3 py-2 text-sm font-medium transition-colors {currentPage === totalPages
|
||||||
|
? 'cursor-not-allowed bg-gray-700 text-gray-500'
|
||||||
|
: 'border border-gray-600 bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white'}"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onclick={handleNext}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page info -->
|
||||||
|
<div class="mt-2 text-center text-sm text-gray-400">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
<div class="mt-4 grid grid-cols-2 gap-2 text-sm text-gray-300">
|
<div class="mt-4 grid grid-cols-2 gap-2 text-sm text-gray-300">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Track:</span>
|
<span class="text-gray-500">Track:</span>
|
||||||
{server.state.track}
|
{server.state?.track}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Players:</span>
|
<span class="text-gray-500">Players:</span>
|
||||||
{server.state.playerCount}
|
{server.state?.playerCount}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,70 @@
|
|||||||
import type { ServerInit } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { redisSessionManager } from '$stores/redisSessionManager';
|
import { redisSessionManager } from '$stores/redisSessionManager';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import type Redis from 'ioredis';
|
import type Redis from 'ioredis';
|
||||||
|
|
||||||
const redisClient: Redis = redisSessionManager['redisClient'];
|
const USER_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes
|
||||||
export const init: ServerInit = async () => {
|
|
||||||
console.log(redisClient.status);
|
interface SessionData {
|
||||||
if (redisClient.status == 'connect') return;
|
token?: string;
|
||||||
await redisClient.connect();
|
user?: any;
|
||||||
|
userFetchedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
// Ensure redis is connected
|
||||||
|
const redisClient: Redis = redisSessionManager['redisClient'];
|
||||||
|
if (redisClient.status !== 'connect' && redisClient.status !== 'ready') {
|
||||||
|
try {
|
||||||
|
await redisClient.connect();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Redis connection failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session from cookie
|
||||||
|
const session = await redisSessionManager.getSession(event.cookies);
|
||||||
|
const sessionData: SessionData = session?.data || {};
|
||||||
|
|
||||||
|
if (!sessionData.token) {
|
||||||
|
event.locals.user = null;
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cached user data is still valid
|
||||||
|
if (sessionData.user && sessionData.userFetchedAt) {
|
||||||
|
const isExpired = Date.now() - sessionData.userFetchedAt > USER_CACHE_DURATION;
|
||||||
|
if (!isExpired) {
|
||||||
|
event.locals.user = sessionData.user;
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh user data
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${env.API_BASE_URL}/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${sessionData.token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const user = await response.json();
|
||||||
|
event.locals.user = user;
|
||||||
|
|
||||||
|
// Cache user data in session
|
||||||
|
sessionData.user = user;
|
||||||
|
sessionData.userFetchedAt = Date.now();
|
||||||
|
await redisSessionManager.createSession(event.cookies, sessionData, user.id);
|
||||||
|
} else {
|
||||||
|
// Token invalid, clear session
|
||||||
|
event.locals.user = null;
|
||||||
|
await redisSessionManager.deleteCookie(event.cookies);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user:', error);
|
||||||
|
event.locals.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Server {
|
export interface Server {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: ServiceStatus;
|
status: ServiceStatus;
|
||||||
state: State;
|
state: State;
|
||||||
|
|||||||
17
src/models/user.ts
Normal file
17
src/models/user.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface Permission {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role_id: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
7
src/routes/+layout.server.ts
Normal file
7
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
let { children } = $props();
|
import { user } from '$stores/user';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
|
let { data, children } = $props<LayoutData>();
|
||||||
|
|
||||||
|
// Set the user store with data from the server
|
||||||
|
$effect(() => {
|
||||||
|
$user = data.user;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
|||||||
27
src/routes/api/membership/+server.ts
Normal file
27
src/routes/api/membership/+server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { fetchAPIEvent } from '$api/apiService';
|
||||||
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
try {
|
||||||
|
const { url } = event;
|
||||||
|
const queryParams = url.searchParams.toString();
|
||||||
|
const endpoint = `/membership${queryParams ? `?${queryParams}` : ''}`;
|
||||||
|
|
||||||
|
const users = await fetchAPIEvent(event, endpoint);
|
||||||
|
return json(users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error);
|
||||||
|
return json({ error: 'Failed to fetch users' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
try {
|
||||||
|
const userData = await event.request.json();
|
||||||
|
const user = await fetchAPIEvent(event, '/membership', 'POST', userData);
|
||||||
|
return json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create user:', error);
|
||||||
|
return json({ error: 'Failed to create user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/routes/api/membership/[id]/+server.ts
Normal file
42
src/routes/api/membership/[id]/+server.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { fetchAPIEvent } from '$api/apiService';
|
||||||
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
try {
|
||||||
|
const { params } = event;
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
const user = await fetchAPIEvent(event, `/membership/${userId}`);
|
||||||
|
return json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user:', error);
|
||||||
|
return json({ error: 'Failed to fetch user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(event: RequestEvent) {
|
||||||
|
try {
|
||||||
|
const { params } = event;
|
||||||
|
const userId = params.id;
|
||||||
|
const userData = await event.request.json();
|
||||||
|
|
||||||
|
const user = await fetchAPIEvent(event, `/membership/${userId}`, 'PUT', userData);
|
||||||
|
return json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update user:', error);
|
||||||
|
return json({ error: 'Failed to update user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(event: RequestEvent) {
|
||||||
|
try {
|
||||||
|
const { params } = event;
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
await fetchAPIEvent(event, `/membership/${userId}`, 'DELETE');
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user:', error);
|
||||||
|
return json({ error: 'Failed to delete user' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,13 @@ import { getServers, restartService, startService, stopService } from '$api/serv
|
|||||||
import { fail, redirect, type Actions, type RequestEvent } from '@sveltejs/kit';
|
import { fail, redirect, type Actions, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const load = async (event: RequestEvent) => {
|
export const load = async (event: RequestEvent) => {
|
||||||
const isAuth = await checkAuth(event);
|
|
||||||
if (!isAuth) return redirect(308, '/login');
|
|
||||||
const servers = await getServers(event);
|
const servers = await getServers(event);
|
||||||
return { servers };
|
return { servers };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to create a server action with validation and error handling
|
// Helper function to create a server action with validation and error handling
|
||||||
const createServerAction = (
|
const createServerAction = (
|
||||||
action: (event: RequestEvent, id: number) => Promise<void>,
|
action: (event: RequestEvent, id: string) => Promise<void>,
|
||||||
{ success, failure }: { success: string; failure: string }
|
{ success, failure }: { success: string; failure: string }
|
||||||
) => {
|
) => {
|
||||||
return async (event: RequestEvent) => {
|
return async (event: RequestEvent) => {
|
||||||
@@ -22,13 +20,8 @@ const createServerAction = (
|
|||||||
return fail(400, { message: 'Invalid server ID provided.' });
|
return fail(400, { message: 'Invalid server ID provided.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverId = Number(id);
|
|
||||||
if (isNaN(serverId)) {
|
|
||||||
return fail(400, { message: 'Server ID must be a number.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await action(event, serverId);
|
await action(event, id);
|
||||||
return { success: true, message: success };
|
return { success: true, message: success };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import ServerCard from '$components/ServerCard.svelte';
|
import ServerCard from '$components/ServerCard.svelte';
|
||||||
import Toast from '$components/Toast.svelte';
|
import Toast from '$components/Toast.svelte';
|
||||||
import type { Server } from '$models/server';
|
import type { Server } from '$models/server';
|
||||||
|
import { user, hasPermission } from '$stores/user';
|
||||||
|
|
||||||
const { data, form } = $props();
|
const { data, form } = $props();
|
||||||
let servers: Server[] = data.servers;
|
let servers: Server[] = data.servers;
|
||||||
@@ -16,6 +17,25 @@
|
|||||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<h1 class="text-2xl font-bold">ACC Server Manager</h1>
|
<h1 class="text-2xl font-bold">ACC Server Manager</h1>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
|
{#if hasPermission($user, 'membership.view')}
|
||||||
|
<a href="/dashboard/membership" class="flex items-center text-gray-300 hover:text-white">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M15 21a6 6 0 00-9-5.197m0 0A5.975 5.975 0 0112 13a5.975 5.975 0 01-3-1.197"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-1 hidden sm:inline">Users</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
<a href="/logout">
|
<a href="/logout">
|
||||||
<button class="flex items-center text-gray-300 hover:text-white">
|
<button class="flex items-center text-gray-300 hover:text-white">
|
||||||
<svg
|
<svg
|
||||||
@@ -67,9 +87,4 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.server-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
114
src/routes/dashboard/membership/+page.server.ts
Normal file
114
src/routes/dashboard/membership/+page.server.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||||
|
import { membershipService } from '$api/membershipService';
|
||||||
|
|
||||||
|
export const load = async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
throw redirect(303, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filter parameters from URL
|
||||||
|
const page = parseInt(event.url.searchParams.get('page') || '1');
|
||||||
|
const pageSize = parseInt(event.url.searchParams.get('page_size') || '10');
|
||||||
|
const username = event.url.searchParams.get('username') || '';
|
||||||
|
const roleName = event.url.searchParams.get('role_name') || '';
|
||||||
|
const sortBy = event.url.searchParams.get('sort_by') || 'username';
|
||||||
|
const sortDesc = event.url.searchParams.get('sort_desc') === 'true';
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
username: username || undefined,
|
||||||
|
role_name: roleName || undefined,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_desc: sortDesc
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [users, roles] = await Promise.all([
|
||||||
|
membershipService.getUsers(event, filter),
|
||||||
|
membershipService.getRoles(event)
|
||||||
|
]);
|
||||||
|
// Simple client-side pagination
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
const paginatedUsers = users.slice(startIndex, endIndex);
|
||||||
|
const totalPages = Math.ceil(users.length / pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: paginatedUsers,
|
||||||
|
roles,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total: users.length,
|
||||||
|
total_pages: totalPages
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
username,
|
||||||
|
role_name: roleName,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_desc: sortDesc
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load users or roles:', error);
|
||||||
|
return {
|
||||||
|
users: [],
|
||||||
|
roles: [],
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
total: 0,
|
||||||
|
total_pages: 0
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
username: '',
|
||||||
|
role_name: '',
|
||||||
|
sort_by: 'username',
|
||||||
|
sort_desc: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
create: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const username = formData.get('username');
|
||||||
|
const password = formData.get('password');
|
||||||
|
const role = formData.get('role');
|
||||||
|
|
||||||
|
if (!username || !password || !role) {
|
||||||
|
return fail(400, { message: 'All fields are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await membershipService.createUser(event, {
|
||||||
|
username: username.toString(),
|
||||||
|
password: password.toString(),
|
||||||
|
role: role.toString()
|
||||||
|
});
|
||||||
|
return { success: true, message: 'User created successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create user:', error);
|
||||||
|
return fail(500, { message: 'Failed to create user' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const id = formData.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return fail(400, { message: 'User ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await membershipService.deleteUser(event, id.toString());
|
||||||
|
return { success: true, message: 'User deleted successfully' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user:', error);
|
||||||
|
return fail(500, { message: 'Failed to delete user' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
379
src/routes/dashboard/membership/+page.svelte
Normal file
379
src/routes/dashboard/membership/+page.svelte
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Pagination from '$components/Pagination.svelte';
|
||||||
|
import Toast from '$components/Toast.svelte';
|
||||||
|
import { user, hasPermission } from '$stores/user';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
const { data, form }: { data: PageData; form?: any } = $props();
|
||||||
|
|
||||||
|
let users = $derived(data.users || []);
|
||||||
|
let roles = $derived(data.roles || []);
|
||||||
|
let pagination = $derived(data.pagination);
|
||||||
|
let filter = $derived(data.filter);
|
||||||
|
|
||||||
|
let usernameFilter = $state('');
|
||||||
|
let roleNameFilter = $state('');
|
||||||
|
let sortBy = $state('username');
|
||||||
|
let sortDesc = $state(false);
|
||||||
|
let showCreateModal = $state(false);
|
||||||
|
let showDeleteModal = $state(false);
|
||||||
|
let selectedUser: any = $state(null);
|
||||||
|
let createForm = $state({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
role: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize filters from data
|
||||||
|
$effect(() => {
|
||||||
|
usernameFilter = filter.username || '';
|
||||||
|
roleNameFilter = filter.role_name || '';
|
||||||
|
sortBy = filter.sort_by || 'username';
|
||||||
|
sortDesc = filter.sort_desc || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Set filter parameters
|
||||||
|
if (usernameFilter) params.set('username', usernameFilter);
|
||||||
|
if (roleNameFilter) params.set('role_name', roleNameFilter);
|
||||||
|
params.set('sort_by', sortBy);
|
||||||
|
params.set('sort_desc', sortDesc.toString());
|
||||||
|
params.set('page', '1'); // Reset to first page
|
||||||
|
|
||||||
|
goto(`?${params.toString()}`, { invalidateAll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
usernameFilter = '';
|
||||||
|
roleNameFilter = '';
|
||||||
|
sortBy = 'username';
|
||||||
|
sortDesc = false;
|
||||||
|
goto('/dashboard/membership', { invalidateAll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(newPage: number) {
|
||||||
|
const params = new URLSearchParams($page.url.searchParams);
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
goto(`?${params.toString()}`, { invalidateAll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSort(field: string) {
|
||||||
|
if (sortBy === field) {
|
||||||
|
sortDesc = !sortDesc;
|
||||||
|
} else {
|
||||||
|
sortBy = field;
|
||||||
|
sortDesc = false;
|
||||||
|
}
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortIcon(field: string) {
|
||||||
|
if (sortBy !== field) return '↕️';
|
||||||
|
return sortDesc ? '↓' : '↑';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
showCreateModal = true;
|
||||||
|
createForm = { username: '', password: '', role: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
showCreateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(userItem: any) {
|
||||||
|
selectedUser = userItem;
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
selectedUser = null;
|
||||||
|
showDeleteModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals after successful form submission
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.success) {
|
||||||
|
showCreateModal = false;
|
||||||
|
showDeleteModal = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if form?.message}
|
||||||
|
<Toast message={form.message} type={form.success ? 'success' : 'error'} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-900 text-white">
|
||||||
|
<header class="bg-gray-800 shadow-md">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="/dashboard" class="text-gray-300 hover:text-white" aria-label="Back to dashboard">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h1 class="text-2xl font-bold">User Management</h1>
|
||||||
|
</div>
|
||||||
|
{#if hasPermission($user, 'membership.create')}
|
||||||
|
<button
|
||||||
|
onclick={openCreateModal}
|
||||||
|
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="mb-6 rounded-lg border border-gray-700 bg-gray-800 p-4">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold">Filters</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-300">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
bind:value={usernameFilter}
|
||||||
|
placeholder="Search by username..."
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="role" class="block text-sm font-medium text-gray-300">Role</label>
|
||||||
|
<input
|
||||||
|
id="role"
|
||||||
|
type="text"
|
||||||
|
bind:value={roleNameFilter}
|
||||||
|
placeholder="Filter by role..."
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex space-x-2">
|
||||||
|
<button
|
||||||
|
onclick={applyFilters}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={resetFilters}
|
||||||
|
class="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Summary -->
|
||||||
|
<div class="mb-4 text-sm text-gray-400">
|
||||||
|
Showing {users.length} of {pagination.total} users
|
||||||
|
{#if pagination.total_pages > 1}
|
||||||
|
(Page {pagination.page} of {pagination.total_pages})
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800">
|
||||||
|
<table class="min-w-full divide-y divide-gray-700">
|
||||||
|
<thead class="bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left">
|
||||||
|
<button
|
||||||
|
class="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase hover:text-white"
|
||||||
|
onclick={() => handleSort('username')}
|
||||||
|
>
|
||||||
|
<span>Username</span>
|
||||||
|
<span>{getSortIcon('username')}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left">
|
||||||
|
<button
|
||||||
|
class="flex items-center space-x-1 text-xs font-medium tracking-wider text-gray-400 uppercase hover:text-white"
|
||||||
|
onclick={() => handleSort('role')}
|
||||||
|
>
|
||||||
|
<span>Role</span>
|
||||||
|
<span>{getSortIcon('role')}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{#if hasPermission($user, 'membership.edit')}
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-400 uppercase"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||||
|
{#each users as userItem (userItem.id)}
|
||||||
|
<tr class="hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-white">
|
||||||
|
{userItem.username}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full bg-blue-900 px-2 py-1 text-xs font-semibold text-blue-300"
|
||||||
|
>
|
||||||
|
{userItem.role.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{#if hasPermission($user, 'membership.edit')}
|
||||||
|
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button class="text-blue-400 hover:text-blue-300"> Edit </button>
|
||||||
|
{#if hasPermission($user, 'membership.delete')}
|
||||||
|
<button
|
||||||
|
onclick={() => openDeleteModal(userItem)}
|
||||||
|
class="text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-6 py-4 text-center text-sm text-gray-400">
|
||||||
|
No users found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if pagination.total_pages > 1}
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
currentPage={pagination.page}
|
||||||
|
totalPages={pagination.total_pages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create User Modal -->
|
||||||
|
{#if showCreateModal}
|
||||||
|
<div class="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
|
||||||
|
<div class="w-full max-w-md rounded-lg bg-gray-800 p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-white">Create New User</h3>
|
||||||
|
<form method="POST" action="?/create">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="create-username" class="block text-sm font-medium text-gray-300"
|
||||||
|
>Username</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="create-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
bind:value={createForm.username}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="create-password" class="block text-sm font-medium text-gray-300"
|
||||||
|
>Password</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="create-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={createForm.password}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="create-role" class="block text-sm font-medium text-gray-300">Role</label>
|
||||||
|
<select
|
||||||
|
id="create-role"
|
||||||
|
name="role"
|
||||||
|
bind:value={createForm.role}
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select a role...</option>
|
||||||
|
{#each roles as role}
|
||||||
|
<option value={role.name}>{role.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeCreateModal}
|
||||||
|
class="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
{#if showDeleteModal && selectedUser}
|
||||||
|
<div class="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
|
||||||
|
<div class="w-full max-w-md rounded-lg bg-gray-800 p-6">
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-white">Delete User</h3>
|
||||||
|
<p class="mb-6 text-gray-300">
|
||||||
|
Are you sure you want to delete the user "{selectedUser.username}"? This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="?/delete">
|
||||||
|
<input type="hidden" name="id" value={selectedUser.id} />
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeDeleteModal}
|
||||||
|
class="rounded-md bg-gray-600 px-4 py-2 text-sm font-medium hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>User Management - ACC Server Manager</title>
|
||||||
|
</svelte:head>
|
||||||
@@ -15,8 +15,6 @@ import { subDays, formatISO } from 'date-fns';
|
|||||||
import { UTCDate } from '@date-fns/utc';
|
import { UTCDate } from '@date-fns/utc';
|
||||||
|
|
||||||
export const load = async (event: RequestEvent) => {
|
export const load = async (event: RequestEvent) => {
|
||||||
const isAuth = await checkAuth(event);
|
|
||||||
if (!isAuth) return redirect(308, '/login');
|
|
||||||
if (!event.params.id) return redirect(308, '/dashboard');
|
if (!event.params.id) return redirect(308, '/dashboard');
|
||||||
const today = new UTCDate();
|
const today = new UTCDate();
|
||||||
const endDate = formatISO(today);
|
const endDate = formatISO(today);
|
||||||
|
|||||||
15
src/stores/user.ts
Normal file
15
src/stores/user.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
import type { User } from '$models/user';
|
||||||
|
|
||||||
|
export const user: Writable<User | null> = writable(null);
|
||||||
|
|
||||||
|
export function hasPermission(user: User | null, permission: string): boolean {
|
||||||
|
if (!user || !user.role || !user.role.permissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Super Admins have all permissions
|
||||||
|
if (user.role.name === 'Super Admin') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return user.role.permissions.some((p) => p.name === permission);
|
||||||
|
}
|
||||||
@@ -3,5 +3,13 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), tailwindcss()]
|
plugins: [sveltekit(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user