10 Commits

Author SHA1 Message Date
Fran Jurmanović
a42e995fd0 update version 2025-07-31 17:34:30 +02:00
Fran Jurmanović
8442aaff8a update service control endpoints 2025-07-29 20:51:28 +02:00
Fran Jurmanović
0472c5e90b remove another console log 2025-07-01 21:45:39 +02:00
Fran Jurmanović
8ccb8033d1 remove console log 2025-07-01 21:44:16 +02:00
Fran Jurmanović
7ae883411b speed up the load times 2025-07-01 21:41:24 +02:00
Fran Jurmanović
995b3e6a63 membership page 2025-06-30 22:51:15 +02:00
Fran Jurmanović
31bed1e6cb fix user data fetch 2025-06-26 01:56:28 +02:00
Fran Jurmanović
a154a4953e fix typo 2025-06-26 00:57:38 +02:00
Fran Jurmanović
283de4f27c fix errors 2025-06-26 00:54:28 +02:00
Fran Jurmanović
53c023ca4d add membership and permissions 2025-06-26 00:52:10 +02:00
23 changed files with 1041 additions and 51 deletions

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "acc-server-manager-web",
"version": "0.0.1",
"version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@@ -1,7 +1,7 @@
{
"name": "acc-server-manager-web",
"private": true,
"version": "0.0.1",
"version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite dev",

View File

@@ -18,7 +18,7 @@ async function fetchAPI(endpoint: string, method: string = 'GET', body?: object,
});
if (!response.ok) {
if (endpoint != '/api' && response.status == 401) {
if (response.status == 401) {
authStore.set({
username: undefined,
password: undefined,
@@ -40,11 +40,15 @@ export async function fetchAPIEvent(
method: string = 'GET',
body?: object
) {
const {
data: { token }
} = await redisSessionManager.getSession(event.cookies);
if (!event.cookies) {
redirect(308, '/login');
}
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;

View File

@@ -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 { redisSessionManager } from '$stores/redisSessionManager';
import type { RequestEvent } from '@sveltejs/kit';
import { v4 as uuidv4 } from 'uuid';
export const login = async (event: RequestEvent, username: string, password: string) => {
const token = btoa(`${username}:${password}`);
await redisSessionManager.createSession(event.cookies, { token }, uuidv4());
if (!(await checkAuth(event))) {
{
authStore.set({ token: undefined, error: 'Invalid username or password.' });
try {
const response = await fetch(`${env.API_BASE_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify({ username, 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;
}
}
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;
}
};
export const logout = (event: RequestEvent) => {

View 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();
}
};

View File

@@ -110,18 +110,18 @@ export const updateConfig = async (
);
};
export const restartService = async (event: RequestEvent, serverId: number) => {
return fetchAPIEvent(event, '/api/restart', 'POST', { serverId });
export const restartService = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service/restart`, 'POST');
};
export const startService = async (event: RequestEvent, serverId: number) => {
return fetchAPIEvent(event, '/api/start', 'POST', { serverId });
export const startService = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service/start`, 'POST');
};
export const stopService = async (event: RequestEvent, serverId: number) => {
return fetchAPIEvent(event, '/api/stop', 'POST', { serverId });
export const stopService = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service/stop`, 'POST');
};
export const getServiceStatus = async (event: RequestEvent, serviceName: string) => {
return fetchAPIEvent(event, `/api/${serviceName}`);
export const getServiceStatus = async (event: RequestEvent, serverId: string) => {
return fetchAPIEvent(event, `/server/${serverId}/service`);
};

4
src/app.d.ts vendored
View File

@@ -3,7 +3,9 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: import('$models/user').User | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}

View 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>

View File

@@ -34,11 +34,11 @@
<div class="mt-4 grid grid-cols-2 gap-2 text-sm text-gray-300">
<div>
<span class="text-gray-500">Track:</span>
{server.state.track}
{server.state?.track}
</div>
<div>
<span class="text-gray-500">Players:</span>
{server.state.playerCount}
{server.state?.playerCount}
</div>
</div>
</div>

View File

@@ -1,10 +1,70 @@
import type { ServerInit } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit';
import { redisSessionManager } from '$stores/redisSessionManager';
import { env } from '$env/dynamic/private';
import type Redis from 'ioredis';
const redisClient: Redis = redisSessionManager['redisClient'];
export const init: ServerInit = async () => {
console.log(redisClient.status);
if (redisClient.status == 'connect') return;
const USER_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes
interface SessionData {
token?: string;
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);
};

View File

@@ -8,7 +8,7 @@ interface State {
}
export interface Server {
id: number;
id: string;
name: string;
status: ServiceStatus;
state: State;

17
src/models/user.ts Normal file
View 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;
}

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};

View File

@@ -1,6 +1,14 @@
<script>
<script lang="ts">
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>
<div class="layout">

View 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 });
}
}

View 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 });
}
}

View File

@@ -3,15 +3,13 @@ import { getServers, restartService, startService, stopService } from '$api/serv
import { fail, redirect, type Actions, type RequestEvent } from '@sveltejs/kit';
export const load = async (event: RequestEvent) => {
const isAuth = await checkAuth(event);
if (!isAuth) return redirect(308, '/login');
const servers = await getServers(event);
return { servers };
};
// Helper function to create a server action with validation and error handling
const createServerAction = (
action: (event: RequestEvent, id: number) => Promise<void>,
action: (event: RequestEvent, id: string) => Promise<void>,
{ success, failure }: { success: string; failure: string }
) => {
return async (event: RequestEvent) => {
@@ -22,13 +20,8 @@ const createServerAction = (
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 {
await action(event, serverId);
await action(event, id);
return { success: true, message: success };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';

View File

@@ -2,6 +2,7 @@
import ServerCard from '$components/ServerCard.svelte';
import Toast from '$components/Toast.svelte';
import type { Server } from '$models/server';
import { user, hasPermission } from '$stores/user';
const { data, form } = $props();
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">
<h1 class="text-2xl font-bold">ACC Server Manager</h1>
<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">
<button class="flex items-center text-gray-300 hover:text-white">
<svg
@@ -67,9 +87,4 @@
</svelte:head>
<style>
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
</style>

View 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;

View 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>

View File

@@ -15,8 +15,6 @@ import { subDays, formatISO } from 'date-fns';
import { UTCDate } from '@date-fns/utc';
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');
const today = new UTCDate();
const endDate = formatISO(today);

15
src/stores/user.ts Normal file
View 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);
}

View File

@@ -3,5 +3,13 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()]
plugins: [sveltekit(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
});