Skip to content

Commit ea409c8

Browse files
committed
Implement admin webhooks page
1 parent b9af6d7 commit ea409c8

6 files changed

Lines changed: 221 additions & 0 deletions

File tree

src/lib/components/layout/AppSidebar.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@
117117
title: 'Users',
118118
href: '/admin/users',
119119
},
120+
{
121+
title: 'Webhooks',
122+
href: '/admin/webhooks',
123+
},
120124
],
121125
},
122126
{
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<script lang="ts">
2+
import type { SortingState } from '@tanstack/table-core';
3+
import { adminApi } from '$lib/api';
4+
import type { WebhookDto } from '$lib/api/internal/v1';
5+
import DataTable from '$lib/components/Table/DataTableTemplate.svelte';
6+
import { Button } from '$lib/components/ui/button';
7+
import { CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
8+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
9+
import { onMount } from 'svelte';
10+
import { columns } from './columns';
11+
import WebhookAddDialog from './dialog-webhook-add.svelte';
12+
13+
let data = $state<WebhookDto[]>([]);
14+
let sorting = $state<SortingState>([]);
15+
16+
let addDialogOpen = $state<boolean>(false);
17+
18+
function fetchWebhooks() {
19+
adminApi
20+
.adminListWebhooks()
21+
.then((res) => {
22+
data = res;
23+
})
24+
.catch(handleApiError);
25+
}
26+
27+
onMount(fetchWebhooks);
28+
</script>
29+
30+
<WebhookAddDialog bind:open={addDialogOpen} onAdded={fetchWebhooks} />
31+
32+
<div class="container my-8">
33+
<CardHeader>
34+
<CardTitle class="flex items-center justify-between space-x-2 text-3xl">
35+
Webhooks
36+
<Button onclick={() => (addDialogOpen = true)}>Add new</Button>
37+
</CardTitle>
38+
</CardHeader>
39+
<CardContent>
40+
<DataTable {data} {columns} {sorting} />
41+
</CardContent>
42+
</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ColumnDef } from '@tanstack/table-core';
2+
import { type WebhookDto } from '$lib/api/internal/v1';
3+
import {
4+
CreateSortableColumnDef,
5+
LocaleDateTimeRenderer,
6+
RenderCell,
7+
} from '$lib/components/Table/ColumnUtils';
8+
import { renderComponent } from '$lib/components/ui/data-table';
9+
import DataTableActions from './data-table-actions.svelte';
10+
11+
export const columns: ColumnDef<WebhookDto>[] = [
12+
CreateSortableColumnDef('name', 'Name', RenderCell),
13+
CreateSortableColumnDef('url', 'Url', RenderCell),
14+
CreateSortableColumnDef('createdAt', 'Created at', LocaleDateTimeRenderer),
15+
{
16+
id: 'actions',
17+
cell: ({ row }) => {
18+
// You can pass whatever you need from `row.original` to the component
19+
return renderComponent(DataTableActions, { webhook: row.original });
20+
},
21+
},
22+
];
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script lang="ts">
2+
import Ellipsis from '@lucide/svelte/icons/ellipsis';
3+
import { RoleType, type WebhookDto } from '$lib/api/internal/v1';
4+
import { Button } from '$lib/components/ui/button';
5+
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
6+
import { toast } from 'svelte-sonner';
7+
import WebhookDeleteDialog from './dialog-webhook-delete.svelte';
8+
9+
type Props = {
10+
webhook: WebhookDto;
11+
};
12+
13+
let { webhook }: Props = $props();
14+
15+
let deleteDialogOpen = $state<boolean>(false);
16+
17+
function copyId() {
18+
navigator.clipboard.writeText(webhook.id);
19+
toast.success('ID copied to clipboard');
20+
}
21+
</script>
22+
23+
<WebhookDeleteDialog bind:open={deleteDialogOpen} {webhook} />
24+
25+
<DropdownMenu.Root>
26+
<DropdownMenu.Trigger>
27+
{#snippet child({ props })}
28+
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
29+
<span class="sr-only">Open menu</span>
30+
<Ellipsis class="size-4" />
31+
</Button>
32+
{/snippet}
33+
</DropdownMenu.Trigger>
34+
<DropdownMenu.Content>
35+
<DropdownMenu.Item onclick={copyId}>Copy ID</DropdownMenu.Item>
36+
<DropdownMenu.Item>Edit</DropdownMenu.Item>
37+
<DropdownMenu.Item onclick={() => (deleteDialogOpen = true)}>Delete</DropdownMenu.Item>
38+
</DropdownMenu.Content>
39+
</DropdownMenu.Root>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<script lang="ts">
2+
import { adminApi } from '$lib/api';
3+
import TextInput from '$lib/components/input/TextInput.svelte';
4+
import { Button } from '$lib/components/ui/button';
5+
import * as Dialog from '$lib/components/ui/dialog';
6+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
7+
import type { ValidationResult } from '$lib/types/ValidationResult';
8+
import { toast } from 'svelte-sonner';
9+
10+
type Props = {
11+
open: boolean;
12+
onAdded: () => void;
13+
};
14+
15+
let { open = $bindable<boolean>(), onAdded }: Props = $props();
16+
17+
let name = $state('');
18+
let url = $state('');
19+
let urlValidationResult = $derived.by<ValidationResult>(() => {
20+
if (url.length == 0) return { valid: true };
21+
22+
// Step 1: Check if URL is a non-empty string
23+
if (url.trim() === '') {
24+
return {
25+
valid: false,
26+
message: 'URL must be a non-empty string',
27+
};
28+
}
29+
30+
// Step 2: Define a regular expression for Discord webhook URLs
31+
const discordWebhookRegex =
32+
/^https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+$/;
33+
34+
// Step 3: Test the URL against the regex
35+
if (!discordWebhookRegex.test(url)) {
36+
return {
37+
valid: false,
38+
message: 'Not a valid Discord webhook URL',
39+
};
40+
}
41+
42+
// Step 4: If all checks pass, the URL is valid
43+
return {
44+
valid: true,
45+
};
46+
});
47+
48+
let valid = $derived(name.length > 0 && url.length > 0 && urlValidationResult.valid);
49+
50+
function createWebhook() {
51+
adminApi
52+
.adminAddWebhook({ name, url })
53+
.then(() => {
54+
onAdded();
55+
toast.success('Created webhook');
56+
open = false;
57+
})
58+
.catch(handleApiError)
59+
.finally(() => (open = false));
60+
}
61+
</script>
62+
63+
<Dialog.Root bind:open={() => open, (o) => (open = o)}>
64+
<Dialog.Content>
65+
<Dialog.Header>
66+
<Dialog.Title>Add webhook</Dialog.Title>
67+
<Dialog.Description>
68+
<strong>We currently only support discord webhooks, womp womp.</strong>
69+
</Dialog.Description>
70+
</Dialog.Header>
71+
<TextInput label="Name" bind:value={name} />
72+
<TextInput label="Url" bind:value={url} validationResult={urlValidationResult} />
73+
<Button onclick={createWebhook} disabled={!valid}>Create</Button>
74+
</Dialog.Content>
75+
</Dialog.Root>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script lang="ts">
2+
import { adminApi } from '$lib/api';
3+
import type { WebhookDto } from '$lib/api/internal/v1';
4+
import Button from '$lib/components/ui/button/button.svelte';
5+
import * as Dialog from '$lib/components/ui/dialog';
6+
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
7+
import { toast } from 'svelte-sonner';
8+
9+
type Props = {
10+
open: boolean;
11+
webhook: WebhookDto;
12+
};
13+
14+
let { open = $bindable<boolean>(), webhook }: Props = $props();
15+
16+
function onDeleteClicked() {
17+
adminApi
18+
.adminRemoveWebhook(webhook.id)
19+
.then(() => {
20+
toast.success('Removed webhook');
21+
open = false;
22+
})
23+
.catch(handleApiError)
24+
.finally(() => (open = false));
25+
}
26+
</script>
27+
28+
<Dialog.Root bind:open={() => open, (o) => (open = o)}>
29+
<Dialog.Content>
30+
<Dialog.Header>
31+
<Dialog.Title>Delete webhook</Dialog.Title>
32+
<Dialog.Description>
33+
Are you sure you want to delete <strong>{webhook.name}</strong>?<br />
34+
<strong>This action is irreversible.</strong>
35+
</Dialog.Description>
36+
</Dialog.Header>
37+
<Button onclick={onDeleteClicked}>Delete</Button>
38+
</Dialog.Content>
39+
</Dialog.Root>

0 commit comments

Comments
 (0)