diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5f777a2..16f72cc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -58,7 +58,9 @@ registrations events import - settings + players + users + settings {/if} diff --git a/src/routes/manage-players/+page.server.ts b/src/routes/manage-players/+page.server.ts new file mode 100644 index 0000000..37f4789 --- /dev/null +++ b/src/routes/manage-players/+page.server.ts @@ -0,0 +1,136 @@ +import { error, redirect, fail } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { scorers, players, teams, divisions } from '$lib/server/db/schema'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) throw redirect(303, '/login'); + + const [row] = await db + .select({ role: scorers.role }) + .from(scorers) + .where(eq(scorers.id, locals.user.id)); + + if (row?.role !== 'admin') throw error(403, 'Forbidden'); + + const playerList = await db + .select({ + id: players.id, + firstName: players.firstName, + lastName: players.lastName, + teamId: players.team, + teamName: teams.name, + divisionId: players.division, + divisionName: divisions.name + }) + .from(players) + .leftJoin(teams, eq(players.team, teams.id)) + .leftJoin(divisions, eq(players.division, divisions.id)) + .orderBy(players.firstName, players.lastName); + + const teamList = await db.select().from(teams).orderBy(teams.name); + const divisionList = await db.select().from(divisions).orderBy(divisions.name); + + return { players: playerList, teams: teamList, divisions: divisionList }; +}; + +export const actions: Actions = { + createPlayer: async ({ locals, request }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const [row] = await db + .select({ role: scorers.role }) + .from(scorers) + .where(eq(scorers.id, locals.user.id)); + + if (row?.role !== 'admin') throw error(403, 'Forbidden'); + + const data = await request.formData(); + const firstName = (data.get('firstName') as string)?.trim(); + const lastName = (data.get('lastName') as string)?.trim(); + const teamId = data.get('teamId') as string; + const divisionId = data.get('divisionId') as string; + + if (!firstName || !lastName || !teamId) { + return fail(400, { createMessage: 'First name, last name, and team are required' }); + } + + await db + .insert(players) + .values({ + firstName, + lastName, + team: parseInt(teamId), + division: divisionId ? parseInt(divisionId) : null + }); + + return { success: true }; + }, + + editPlayer: async ({ locals, request }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const [row] = await db + .select({ role: scorers.role }) + .from(scorers) + .where(eq(scorers.id, locals.user.id)); + + if (row?.role !== 'admin') throw error(403, 'Forbidden'); + + const data = await request.formData(); + const playerId = data.get('playerId') as string; + const firstName = (data.get('firstName') as string)?.trim(); + const lastName = (data.get('lastName') as string)?.trim(); + const teamId = data.get('teamId') as string; + const divisionId = data.get('divisionId') as string; + + if (!playerId || !firstName || !lastName || !teamId) { + return fail(400, { editMessage: 'First name, last name, and team are required' }); + } + + const [existing] = await db.select().from(players).where(eq(players.id, parseInt(playerId))); + if (!existing) { + return fail(404, { editMessage: 'Player not found' }); + } + + await db + .update(players) + .set({ + firstName, + lastName, + team: parseInt(teamId), + division: divisionId ? parseInt(divisionId) : null + }) + .where(eq(players.id, parseInt(playerId))); + + return { success: true }; + }, + + deletePlayer: async ({ locals, request }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const [row] = await db + .select({ role: scorers.role }) + .from(scorers) + .where(eq(scorers.id, locals.user.id)); + + if (row?.role !== 'admin') throw error(403, 'Forbidden'); + + const data = await request.formData(); + const playerId = data.get('playerId') as string; + + if (!playerId) { + return fail(400, { deleteMessage: 'Player ID required' }); + } + + const [existing] = await db.select().from(players).where(eq(players.id, parseInt(playerId))); + if (!existing) { + return fail(404, { deleteMessage: 'Player not found' }); + } + + await db.delete(players).where(eq(players.id, parseInt(playerId))); + + return { success: true }; + } +}; diff --git a/src/routes/manage-players/+page.svelte b/src/routes/manage-players/+page.svelte new file mode 100644 index 0000000..fe094eb --- /dev/null +++ b/src/routes/manage-players/+page.svelte @@ -0,0 +1,383 @@ + + +
+
Manage Players
+ + +
+ Add New Player + +
+ + {#if showCreateForm} +
+
Create a new player
+
{ + return async ({ result }) => { + if (result.type === 'success') { + resetCreateForm(); + await invalidateAll(); + } else if (result.type === 'failure') { + const d = result.data as { createMessage?: string } | null; + createMessage = d?.createMessage ?? 'Failed to create player'; + } + }; + }} + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {#if createMessage} +

{createMessage}

+ {/if} +
+
+ {/if} + + + {#if editingId !== null} +
+ Edit Player + +
+ +
+
Update player details
+
{ + return async ({ result }) => { + if (result.type === 'success') { + cancelEdit(); + await invalidateAll(); + } else if (result.type === 'failure') { + const d = result.data as { editMessage?: string } | null; + editMessage = d?.editMessage ?? 'Failed to update player'; + } + }; + }} + > + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ {#if editMessage} +

{editMessage}

+ {/if} +
+
+ {/if} + + +
+ All Players + {filteredPlayers.length} / {data.players.length} entries +
+ + + +
+ + {#snippet header()} + + + + + + {/snippet} + {#snippet row(player: (typeof data.players)[number])} + + + + + + {/snippet} +
First NameLast NameTeamDivisionActions{player.firstName}{player.lastName}{player.teamName}{player.divisionName ?? '-'} + +
{ + return async ({ result }) => { + if (result.type === 'success') { + await invalidateAll(); + } else if (result.type === 'failure') { + const d = result.data as { deleteMessage?: string } | null; + deleteMessage = d?.deleteMessage ?? 'Failed to delete player'; + } + }; + }} + style="display: inline;" + onsubmit={(e) => { + if (!confirm(`Delete ${player.firstName} ${player.lastName}?`)) { + e.preventDefault(); + } + }} + > + + +
+
+ {#if deleteMessage} +

{deleteMessage}

+ {/if} +
+
+ + diff --git a/src/routes/manage-users/+page.server.ts b/src/routes/manage-users/+page.server.ts new file mode 100644 index 0000000..9139364 --- /dev/null +++ b/src/routes/manage-users/+page.server.ts @@ -0,0 +1,90 @@ +import { error, redirect, fail } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { scorers } from '$lib/server/db/schema'; +import { hash } from '$lib/server/password'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) { + throw redirect(303, '/login'); + } + + const [row] = await db + .select({ role: scorers.role }) + .from(scorers) + .where(eq(scorers.id, locals.user.id)); + + if (row?.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const users = await db + .select({ id: scorers.id, username: scorers.username, role: scorers.role }) + .from(scorers) + .orderBy(scorers.username); + + return { users }; +}; + +export const actions: Actions = { + register: async ({ locals, request }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const [row] = await db + .select({ role: scorers.role }) + .from(scorers) + .where(eq(scorers.id, locals.user.id)); + + if (row?.role !== 'admin') throw error(403, 'Forbidden'); + + const data = await request.formData(); + const username = data.get('username') as string; + const password = data.get('password') as string; + + if (!username || !password || password.length < 8) { + return fail(400, { registerMessage: 'Username and an 8+ character password are required' }); + } + + const [existing] = await db.select().from(scorers).where(eq(scorers.username, username)); + if (existing) { + return fail(400, { registerMessage: 'Username already taken' }); + } + + const passwordHash = await hash(password); + const userId = crypto.randomUUID(); + + await db.insert(scorers).values({ id: userId, username, passwordHash }); + + return { success: true }; + }, + + changePassword: async ({ locals, request }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const [row] = await db + .select({ role: scorers.role }) + .from(scorers) + .where(eq(scorers.id, locals.user.id)); + + if (row?.role !== 'admin') throw error(403, 'Forbidden'); + + const data = await request.formData(); + const userId = data.get('userId') as string; + const newPassword = data.get('newPassword') as string; + + if (!userId || !newPassword || newPassword.length < 8) { + return fail(400, { passwordMessage: 'User selection and an 8+ character password are required' }); + } + + const [user] = await db.select().from(scorers).where(eq(scorers.id, userId)); + if (!user) { + return fail(404, { passwordMessage: 'User not found' }); + } + + const passwordHash = await hash(newPassword); + await db.update(scorers).set({ passwordHash }).where(eq(scorers.id, userId)); + + return { success: true }; + } +}; diff --git a/src/routes/manage-users/+page.svelte b/src/routes/manage-users/+page.svelte new file mode 100644 index 0000000..85776f1 --- /dev/null +++ b/src/routes/manage-users/+page.svelte @@ -0,0 +1,246 @@ + + +
+
Manage Users
+ + +
+ Register New Scorer + +
+ + {#if showRegisterForm} +
+
Create a new scorer account
+
{ + return async ({ result }) => { + if (result.type === 'success') { + resetRegisterForm(); + await invalidateAll(); + } else if (result.type === 'failure') { + const d = result.data as { registerMessage?: string } | null; + registerMessage = d?.registerMessage ?? 'Registration failed'; + } + }; + }} + > +
+
+ + +
+
+ + +
+
+ +
+
+ {#if registerMessage} +

{registerMessage}

+ {/if} +
+
+ {/if} + + +
+ Change Password + +
+ + {#if showPasswordForm} +
+
Set a new password for an existing scorer
+
{ + return async ({ result }) => { + if (result.type === 'success') { + resetPasswordForm(); + } else if (result.type === 'failure') { + const d = result.data as { passwordMessage?: string } | null; + passwordMessage = d?.passwordMessage ?? 'Password change failed'; + } + }; + }} + > +
+
+ + +
+
+ + +
+
+ +
+
+ {#if passwordMessage} +

{passwordMessage}

+ {/if} +
+
+ {/if} + + +
+ All Scorers + {data.users.length} entries +
+ + + {#snippet header()} + + + {/snippet} + {#snippet row(user: (typeof data.users)[number])} + + + {/snippet} +
UsernameRole{user.username}{user.role}
+
+ +