add blocked user
All checks were successful
deploy / build-and-deploy (push) Successful in 15s

This commit is contained in:
bytedream 2025-05-20 23:34:54 +02:00
parent ba1146facf
commit 8b18623232
14 changed files with 390 additions and 1 deletions

View File

@ -66,6 +66,17 @@ export const signup = {
}); });
} }
// check if user is blocked
if (uuid) {
const blockedUser = await db.getBlockedUserByUuid({ uuid: uuid });
if (blockedUser) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Du bist für die Registrierung gesperrt'
});
}
}
if (!teamDraft) { if (!teamDraft) {
// check if a team with the same name already exists // check if a team with the same name already exists
if (input.teamName) { if (input.teamName) {

View File

@ -84,5 +84,41 @@ export const user = {
users: users users: users
}; };
} }
}),
addBlocked: defineAction({
input: z.object({
uuid: z.string(),
comment: z.string().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
const { id } = await db.addBlockedUser(input);
return {
id: id
};
}
}),
editBlocked: defineAction({
input: z.object({
id: z.number(),
uuid: z.string(),
comment: z.string().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
await db.editBlockedUser(input);
}
}),
blocked: defineAction({
handler: async (_, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
return {
blocked: await db.getBlockedUsers({})
};
}
}) })
}; };

View File

@ -0,0 +1,101 @@
<script lang="ts">
import Input from '@components/input/Input.svelte';
import Textarea from '@components/input/Textarea.svelte';
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import type { BlockedUser } from '@app/admin/usersBlocked/types.ts';
import { blockedUserCreateOrEditPopupState } from '@app/admin/usersBlocked/state.ts';
import { onDestroy } from 'svelte';
// html bindings
let modal: HTMLDialogElement;
let modalForm: HTMLFormElement;
// states
let action = $state<'create' | 'edit' | null>(null);
let blockedUser = $state({} as BlockedUser);
let onUpdate = $state((_: BlockedUser) => {});
let submitEnabled = $derived(!!blockedUser.uuid);
// lifecycle
const cancel = blockedUserCreateOrEditPopupState.subscribe((value) => {
if (value && 'create' in value) {
action = 'create';
blockedUser = {
id: -1,
uuid: '',
comment: null
};
onUpdate = value?.create.onUpdate;
modal.show();
} else if (value && 'edit' in value) {
action = 'edit';
blockedUser = value.edit.blockedUser;
onUpdate = value.edit.onUpdate;
modal.show();
}
});
onDestroy(cancel);
// texts
const texts = {
create: {
title: 'Blockierten Nutzer erstellen',
buttonTitle: 'Erstellen',
confirmPopupTitle: 'Nutzer blockieren?',
confirmPopupMessage:
'Bist du sicher, dass der Nutzer blockiert werden soll?\nEin blockierter Nutzer kann sich nicht mehr registrieren.'
},
edit: {
title: 'Blockierten Nutzer bearbeiten',
buttonTitle: 'Speichern',
confirmPopupTitle: 'Änderungen speichern?',
confirmPopupMessage: 'Sollen die Änderungen gespeichert werden?'
},
null: {}
};
// callbacks
async function onSaveButtonClick(e: Event) {
e.preventDefault();
$confirmPopupState = {
title: texts[action!].confirmPopupTitle,
message: texts[action!].confirmPopupMessage,
onConfirm: () => {
modalForm.submit();
onUpdate(blockedUser);
}
};
}
function onCancelButtonClick(e: Event) {
e.preventDefault();
modalForm.submit();
}
</script>
<dialog class="modal" bind:this={modal}>
<form method="dialog" class="modal-box" bind:this={modalForm}>
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onCancelButtonClick}>✕</button>
<div class="space-y-5">
<h3 class="text-xt font-geist font-bold">{texts[action!].title}</h3>
<div>
<Input bind:value={blockedUser.uuid} label="UUID" required dynamicWidth />
<Textarea label="Kommentar" bind:value={blockedUser.comment} rows={3} dynamicWidth />
</div>
<div>
<button
class="btn btn-success"
class:disabled={!submitEnabled}
disabled={!submitEnabled}
onclick={onSaveButtonClick}>{texts[action!].buttonTitle}</button
>
<button class="btn btn-error" onclick={onCancelButtonClick}>Abbrechen</button>
</div>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { addBlockedUser, fetchBlockedUsers } from '@app/admin/usersBlocked/actions.ts';
import { blockedUserCreateOrEditPopupState } from '@app/admin/usersBlocked/state.ts';
// lifecycle
$effect(() => {
fetchBlockedUsers();
});
// callbacks
async function onNewUserButtonClick() {
$blockedUserCreateOrEditPopupState = {
create: {
onUpdate: addBlockedUser
}
};
}
</script>
<div>
<button class="btn btn-soft w-full" onclick={() => onNewUserButtonClick()}>
<Icon icon="heroicons:plus-16-solid" />
<span>Neuer blockierter Nutzer</span>
</button>
</div>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import CreateOrEditPopup from './CreateOrEditPopup.svelte';
import SortableTr from '@components/admin/table/SortableTr.svelte';
import SortableTh from '@components/admin/table/SortableTh.svelte';
import type { BlockedUser } from '@app/admin/usersBlocked/types.ts';
import { blockedUserCreateOrEditPopupState, blockedUsers } from '@app/admin/usersBlocked/state.ts';
import { editBlockedUser } from '@app/admin/usersBlocked/actions.ts';
// callbacks
async function onBlockedUserEditButtonClick(blockedUser: BlockedUser) {
$blockedUserCreateOrEditPopupState = {
edit: {
blockedUser: blockedUser,
onUpdate: editBlockedUser
}
};
}
</script>
<div class="h-screen overflow-x-auto">
<table class="table table-pin-rows">
<thead>
<SortableTr data={blockedUsers}>
<SortableTh style="width: 5%">#</SortableTh>
<SortableTh style="width: 20%" key="uuid">UUID</SortableTh>
<SortableTh style="width: 70%">Kommentar</SortableTh>
<SortableTh style="width: 5%"></SortableTh>
</SortableTr>
</thead>
<tbody>
{#each $blockedUsers as blockedUser, i (blockedUser)}
<tr class="hover:bg-base-200">
<td>{i + 1}</td>
<td>{blockedUser.uuid}</td>
<td>{blockedUser.comment}</td>
<td>
<button class="cursor-pointer" onclick={() => onBlockedUserEditButtonClick(blockedUser)}>
<Icon icon="heroicons:pencil-square" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<CreateOrEditPopup />

View File

@ -0,0 +1,41 @@
import { actions } from 'astro:actions';
import { actionErrorPopup } from '@util/action.ts';
import { blockedUsers } from '@app/admin/usersBlocked/state.ts';
import type { BlockedUser } from '@app/admin/usersBlocked/types.ts';
export async function fetchBlockedUsers() {
const { data, error } = await actions.user.blocked();
if (error) {
actionErrorPopup(error);
return;
}
blockedUsers.set(data.blocked);
}
export async function addBlockedUser(blockedUser: BlockedUser) {
const { data, error } = await actions.user.addBlocked(blockedUser);
if (error) {
actionErrorPopup(error);
return;
}
blockedUsers.update((old) => {
old.push(Object.assign(blockedUser, { id: data.id }));
return old;
});
}
export async function editBlockedUser(blockedUser: BlockedUser) {
const { data, error } = await actions.user.editBlocked(blockedUser);
if (error) {
actionErrorPopup(error);
return;
}
blockedUsers.update((old) => {
const index = old.findIndex((a) => a.id == user.id);
old[index] = blockedUser;
return old;
});
}

View File

@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
import type { BlockedUserCreateOrEditPopupState, BlockedUsers } from '@app/admin/usersBlocked/types.ts';
export const blockedUsers = writable<BlockedUsers>([]);
export const blockedUserCreateOrEditPopupState = writable<BlockedUserCreateOrEditPopupState>(null);

View File

@ -0,0 +1,9 @@
import { type ActionReturnType, actions } from 'astro:actions';
export type BlockedUsers = Exclude<ActionReturnType<typeof actions.user.blocked>['data'], undefined>['blocked'];
export type BlockedUser = BlockedUsers[0];
export type BlockedUserCreateOrEditPopupState =
| { create: { onUpdate: (blockedUser: BlockedUser) => void } }
| { edit: { blockedUser: BlockedUser; onUpdate: (blockedUser: BlockedUser) => void } }
| null;

View File

@ -28,6 +28,13 @@ CREATE TRIGGER IF NOT EXISTS user_username_update AFTER UPDATE ON user
END; END;
DELIMITER ; DELIMITER ;
-- blocked user
CREATE TABLE IF NOT EXISTS blocked_user (
id INT AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(255) UNIQUE NOT NULL,
comment TINYTEXT
);
-- team -- team
CREATE TABLE IF NOT EXISTS team ( CREATE TABLE IF NOT EXISTS team (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,

View File

@ -105,6 +105,17 @@ import {
type GetReportStatusReq, type GetReportStatusReq,
reportStatus reportStatus
} from '@db/schema/reportStatus.ts'; } from '@db/schema/reportStatus.ts';
import {
addBlockedUser,
type AddBlockedUserReq,
getBlockedUsers,
type GetBlockedUsersReq,
blockedUser,
type GetBlockedUserByUuidReq,
getBlockedUserByUuid,
type EditBlockedUserReq,
editBlockedUser
} from '@db/schema/blockedUser.ts';
export class Database { export class Database {
protected readonly db: MySql2Database<{ protected readonly db: MySql2Database<{
@ -113,6 +124,7 @@ export class Database {
teamDraft: typeof teamDraft; teamDraft: typeof teamDraft;
teamMember: typeof teamMember; teamMember: typeof teamMember;
user: typeof user; user: typeof user;
blockedUser: typeof blockedUser;
death: typeof death; death: typeof death;
report: typeof report; report: typeof report;
reportStatus: typeof reportStatus; reportStatus: typeof reportStatus;
@ -139,6 +151,7 @@ export class Database {
teamDraft, teamDraft,
teamMember, teamMember,
user, user,
blockedUser,
death, death,
report, report,
reportStatus, reportStatus,
@ -173,6 +186,12 @@ export class Database {
getUserByUsername = (values: GetUserByUsernameReq) => getUserByUsername(this.db, values); getUserByUsername = (values: GetUserByUsernameReq) => getUserByUsername(this.db, values);
getUsersByUuid = (values: GetUsersByUuidReq) => getUsersByUuid(this.db, values); getUsersByUuid = (values: GetUsersByUuidReq) => getUsersByUuid(this.db, values);
/* user blocks */
addBlockedUser = (values: AddBlockedUserReq) => addBlockedUser(this.db, values);
editBlockedUser = (values: EditBlockedUserReq) => editBlockedUser(this.db, values);
getBlockedUserByUuid = (values: GetBlockedUserByUuidReq) => getBlockedUserByUuid(this.db, values);
getBlockedUsers = (values: GetBlockedUsersReq) => getBlockedUsers(this.db, values);
/* team */ /* team */
addTeam = (values: AddTeamReq) => addTeam(this.db, values); addTeam = (values: AddTeamReq) => addTeam(this.db, values);
editTeam = (values: EditTeamReq) => editTeam(this.db, values); editTeam = (values: EditTeamReq) => editTeam(this.db, values);

View File

@ -0,0 +1,50 @@
import { int, mysqlTable, varchar } from 'drizzle-orm/mysql-core';
import type { MySql2Database } from 'drizzle-orm/mysql2';
import { eq } from 'drizzle-orm';
type Database = MySql2Database<{ blockedUser: typeof blockedUser }>;
export const blockedUser = mysqlTable('blocked_user', {
id: int('id').primaryKey().autoincrement(),
uuid: varchar('uuid', { length: 255 }).unique().notNull(),
comment: varchar('comment', { length: 255 })
});
export type AddBlockedUserReq = {
uuid: string;
comment: string | null;
};
export type EditBlockedUserReq = {
id: number;
uuid: string;
comment: string | null;
};
export type GetBlockedUserByUuidReq = {
uuid: string;
};
export type GetBlockedUsersReq = {};
export async function addBlockedUser(db: Database, values: AddBlockedUserReq) {
const bu = await db.insert(blockedUser).values(values).$returningId();
return bu[0];
}
export async function editBlockedUser(db: Database, values: EditBlockedUserReq) {
await db.update(blockedUser).set(values).where(eq(blockedUser.id, values.id));
}
export async function getBlockedUserByUuid(db: Database, values: GetBlockedUserByUuidReq) {
const bu = await db.query.blockedUser.findFirst({
where: eq(blockedUser.uuid, values.uuid)
});
return bu ?? null;
}
export async function getBlockedUsers(db: Database, _values: GetBlockedUsersReq) {
return db.select().from(blockedUser);
}

View File

@ -27,6 +27,13 @@ const adminTabs = [
href: 'admin/users', href: 'admin/users',
name: 'Registrierte Nutzer', name: 'Registrierte Nutzer',
icon: 'heroicons:user', icon: 'heroicons:user',
subTabs: [
{
href: 'admin/users/blocked',
name: 'Blockierte Nutzer',
icon: 'heroicons:user-minus'
}
],
enabled: session?.permissions.users enabled: session?.permissions.users
}, },
{ {
@ -65,7 +72,7 @@ const adminTabs = [
<BaseLayout title={title}> <BaseLayout title={title}>
<ClientRouter /> <ClientRouter />
<div class="flex flex-row max-h-[100vh]"> <div class="flex flex-row max-h-[100vh]">
<ul class="menu bg-base-200 w-64 h-[100vh] flex"> <ul class="menu bg-base-200 w-68 h-[100vh] flex">
{ {
preTabs.map((tab) => ( preTabs.map((tab) => (
<li> <li>
@ -84,6 +91,18 @@ const adminTabs = [
<Icon name={tab.icon} /> <Icon name={tab.icon} />
<span>{tab.name}</span> <span>{tab.name}</span>
</a> </a>
{tab.subTabs && (
<ul>
{tab.subTabs.map((subTab) => (
<li>
<a href={subTab.href}>
<Icon name={subTab.icon} />
<span>{subTab.name}</span>
</a>
</li>
))}
</ul>
)}
</li> </li>
)) ))
} }

View File

@ -0,0 +1,16 @@
---
import AdminLayout from '@layouts/admin/AdminLayout.astro';
import UsersBlocked from '@app/admin/usersBlocked/UsersBlocked.svelte';
import SidebarActions from '@app/admin/usersBlocked/SidebarActions.svelte';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { BASE_PATH } from 'astro:env/server';
const session = Session.sessionFromCookies(Astro.cookies, Permissions.Admin);
if (!session) return Astro.redirect(`${BASE_PATH}/admin`);
---
<AdminLayout title="Blockierte Nutzer">
<SidebarActions slot="actions" client:load />
<UsersBlocked client:load />
</AdminLayout>