Skip to content

Commit b790835

Browse files
committed
next: Added api keys page
1 parent 7c69e0e commit b790835

File tree

16 files changed

+876
-2
lines changed

16 files changed

+876
-2
lines changed

src/Exceptionless.Web/ClientApp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,4 @@
9292
"throttle-debounce": "^5.0.2"
9393
},
9494
"type": "module"
95-
}
95+
}

src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder-context.svelte.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ export interface FacetFilterBuilder<TFilter extends IFilter> {
1111
title: string;
1212
}
1313

14-
export const builderContext = $state(new SvelteMap<string, FacetFilterBuilder<IFilter>>());
14+
export const builderContext = new SvelteMap<string, FacetFilterBuilder<IFilter>>();
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Root from "./textarea.svelte";
2+
3+
type FormTextareaEvent<T extends Event = Event> = T & {
4+
currentTarget: EventTarget & HTMLTextAreaElement;
5+
};
6+
7+
type TextareaEvents = {
8+
blur: FormTextareaEvent<FocusEvent>;
9+
change: FormTextareaEvent<Event>;
10+
click: FormTextareaEvent<MouseEvent>;
11+
focus: FormTextareaEvent<FocusEvent>;
12+
keydown: FormTextareaEvent<KeyboardEvent>;
13+
keypress: FormTextareaEvent<KeyboardEvent>;
14+
keyup: FormTextareaEvent<KeyboardEvent>;
15+
mouseover: FormTextareaEvent<MouseEvent>;
16+
mouseenter: FormTextareaEvent<MouseEvent>;
17+
mouseleave: FormTextareaEvent<MouseEvent>;
18+
paste: FormTextareaEvent<ClipboardEvent>;
19+
input: FormTextareaEvent<InputEvent>;
20+
};
21+
22+
export {
23+
Root,
24+
//
25+
Root as Textarea,
26+
type TextareaEvents,
27+
type FormTextareaEvent,
28+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script lang="ts">
2+
import type { WithElementRef, WithoutChildren } from "bits-ui";
3+
import type { HTMLTextareaAttributes } from "svelte/elements";
4+
import { cn } from "$lib/utils.js";
5+
6+
let {
7+
ref = $bindable(null),
8+
value = $bindable(),
9+
class: className,
10+
...restProps
11+
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
12+
</script>
13+
14+
<textarea
15+
bind:this={ref}
16+
bind:value
17+
class={cn(
18+
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
19+
className
20+
)}
21+
{...restProps}
22+
></textarea>
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { NewToken, UpdateToken, ViewToken } from '$features/tokens/models';
2+
import type { WebSocketMessageValue } from '$features/websockets/models';
3+
4+
import { accessToken } from '$features/auth/index.svelte';
5+
import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte';
6+
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
7+
import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query';
8+
9+
export async function invalidateTokenQueries(queryClient: QueryClient, message: WebSocketMessageValue<'TokenChanged'>) {
10+
const { id, organization_id, project_id } = message;
11+
console.log('invalidateTokenQueries', message);
12+
if (id) {
13+
await queryClient.invalidateQueries({ queryKey: queryKeys.id(id) });
14+
}
15+
16+
// if (organization_id) {
17+
// await queryClient.invalidateQueries({ queryKey: queryKeys.organization(organization_id) });
18+
// }
19+
20+
if (project_id) {
21+
await queryClient.invalidateQueries({ queryKey: queryKeys.project(project_id) });
22+
}
23+
24+
if (!id && !organization_id && !project_id) {
25+
await queryClient.invalidateQueries({ queryKey: queryKeys.type });
26+
}
27+
}
28+
29+
// TODO: Do we need to scope these all by organization?
30+
export const queryKeys = {
31+
deleteToken: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const,
32+
id: (id: string | undefined) => [...queryKeys.type, id] as const,
33+
ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const,
34+
postProjectToken: (id: string | undefined) => [...queryKeys.project(id), 'post'] as const,
35+
project: (id: string | undefined) => [...queryKeys.type, 'project', id] as const,
36+
type: ['Token'] as const
37+
};
38+
39+
export interface DeleteTokenRequest {
40+
route: {
41+
ids: string[];
42+
};
43+
}
44+
45+
export interface GetProjectTokensParams {
46+
limit?: number;
47+
page?: number;
48+
}
49+
50+
export interface GetProjectTokensRequest {
51+
params: GetProjectTokensParams;
52+
route: {
53+
projectId: string | undefined;
54+
};
55+
}
56+
57+
export interface PatchTokenRequest {
58+
route: {
59+
id: string | undefined;
60+
};
61+
}
62+
63+
export interface PostProjectTokenRequest {
64+
route: {
65+
projectId: string;
66+
};
67+
}
68+
69+
export function deleteToken(request: DeleteTokenRequest) {
70+
const queryClient = useQueryClient();
71+
72+
return createMutation<FetchClientResponse<unknown>, ProblemDetails, void>(() => ({
73+
enabled: () => !!accessToken.current && !!request.route.ids?.length,
74+
mutationFn: async () => {
75+
const client = useFetchClient();
76+
const response = await client.delete(`tokens/${request.route.ids?.join(',')}`, {
77+
expectedStatusCodes: [202]
78+
});
79+
80+
return response;
81+
},
82+
mutationKey: queryKeys.deleteToken(request.route.ids),
83+
onError: () => {
84+
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
85+
},
86+
onSuccess: () => {
87+
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id) }));
88+
}
89+
}));
90+
}
91+
92+
export function getProjectTokensQuery(request: GetProjectTokensRequest) {
93+
const queryClient = useQueryClient();
94+
95+
return createQuery<FetchClientResponse<ViewToken[]>, ProblemDetails>(() => ({
96+
enabled: () => !!accessToken.current && !!request.route.projectId,
97+
onSuccess: (data: ViewToken[]) => {
98+
data.forEach((token) => {
99+
queryClient.setQueryData(queryKeys.id(token.id!), token);
100+
});
101+
},
102+
queryClient,
103+
queryFn: async ({ signal }: { signal: AbortSignal }) => {
104+
const client = useFetchClient();
105+
const response = await client.getJSON<ViewToken[]>(`projects/${request.route.projectId}/tokens`, {
106+
params: {
107+
...request.params,
108+
limit: request.params?.limit ?? DEFAULT_LIMIT
109+
},
110+
signal
111+
});
112+
113+
return response;
114+
},
115+
queryKey: queryKeys.project(request.route.projectId)
116+
}));
117+
}
118+
119+
export function patchToken(request: PatchTokenRequest) {
120+
const queryClient = useQueryClient();
121+
122+
return createMutation<ViewToken, ProblemDetails, UpdateToken>(() => ({
123+
mutationFn: async (data: UpdateToken) => {
124+
const client = useFetchClient();
125+
const response = await client.patchJSON<ViewToken>(`tokens/${request.route.id}`, data);
126+
return response.data!;
127+
},
128+
mutationKey: queryKeys.id(request.route.id),
129+
onError: () => {
130+
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) });
131+
},
132+
onSuccess: (token: ViewToken) => {
133+
queryClient.setQueryData(queryKeys.id(request.route.id), token);
134+
}
135+
}));
136+
}
137+
138+
export function postProjectToken(request: PostProjectTokenRequest) {
139+
const queryClient = useQueryClient();
140+
141+
return createMutation<ViewToken, ProblemDetails, NewToken>(() => ({
142+
enabled: () => !!accessToken.current && request.route.projectId,
143+
mutationFn: async (token: NewToken) => {
144+
const client = useFetchClient();
145+
const response = await client.postJSON<ViewToken>(`projects/${request.route.projectId}/tokens`, token);
146+
return response.data!;
147+
},
148+
mutationKey: queryKeys.postProjectToken(request.route.projectId),
149+
onSuccess: (token: ViewToken) => {
150+
queryClient.invalidateQueries({ queryKey: queryKeys.type });
151+
queryClient.setQueryData(queryKeys.id(token.id), token);
152+
}
153+
}));
154+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<script lang="ts">
2+
import * as AlertDialog from '$comp/ui/alert-dialog';
3+
import { buttonVariants } from '$comp/ui/button';
4+
5+
interface Props {
6+
disable: () => Promise<void>;
7+
id: string;
8+
notes?: null | string;
9+
open: boolean;
10+
}
11+
12+
let { disable, id, notes, open = $bindable(false) }: Props = $props();
13+
14+
async function onSubmit() {
15+
await disable();
16+
open = false;
17+
}
18+
</script>
19+
20+
<AlertDialog.Root bind:open>
21+
<AlertDialog.Content>
22+
<AlertDialog.Header>
23+
<AlertDialog.Title>Disable API Key</AlertDialog.Title>
24+
<AlertDialog.Description>
25+
Are you sure you want to disable "{id}" {#if notes}({notes}){/if}?
26+
</AlertDialog.Description>
27+
</AlertDialog.Header>
28+
<AlertDialog.Footer>
29+
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
30+
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={onSubmit}>Disable API Key</AlertDialog.Action>
31+
</AlertDialog.Footer>
32+
</AlertDialog.Content>
33+
</AlertDialog.Root>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script lang="ts">
2+
import * as AlertDialog from '$comp/ui/alert-dialog';
3+
4+
interface Props {
5+
enable: () => Promise<void>;
6+
id: string;
7+
notes?: null | string;
8+
open: boolean;
9+
}
10+
11+
let { enable, id, notes, open = $bindable(false) }: Props = $props();
12+
13+
async function onSubmit() {
14+
await enable();
15+
open = false;
16+
}
17+
</script>
18+
19+
<AlertDialog.Root bind:open>
20+
<AlertDialog.Content>
21+
<AlertDialog.Header>
22+
<AlertDialog.Title>Enable API Key</AlertDialog.Title>
23+
<AlertDialog.Description>
24+
Are you sure you want to enable "{id}" {#if notes}({notes}){/if}?
25+
</AlertDialog.Description>
26+
</AlertDialog.Header>
27+
<AlertDialog.Footer>
28+
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
29+
<AlertDialog.Action onclick={onSubmit}>Enable API Key</AlertDialog.Action>
30+
</AlertDialog.Footer>
31+
</AlertDialog.Content>
32+
</AlertDialog.Root>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<script lang="ts">
2+
import * as AlertDialog from '$comp/ui/alert-dialog';
3+
import { buttonVariants } from '$comp/ui/button';
4+
5+
interface Props {
6+
id: string;
7+
notes?: null | string;
8+
open: boolean;
9+
remove: () => Promise<void>;
10+
}
11+
12+
let { id, notes, open = $bindable(false), remove }: Props = $props();
13+
14+
async function onSubmit() {
15+
await remove();
16+
open = false;
17+
}
18+
</script>
19+
20+
<AlertDialog.Root bind:open>
21+
<AlertDialog.Content>
22+
<AlertDialog.Header>
23+
<AlertDialog.Title>Delete API Key</AlertDialog.Title>
24+
<AlertDialog.Description>
25+
Are you sure you want to delete "{id}" {#if notes}({notes}){/if}?
26+
</AlertDialog.Description>
27+
</AlertDialog.Header>
28+
<AlertDialog.Footer>
29+
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
30+
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={onSubmit}>Delete API Key</AlertDialog.Action>
31+
</AlertDialog.Footer>
32+
</AlertDialog.Content>
33+
</AlertDialog.Root>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<script lang="ts">
2+
import { P } from '$comp/typography';
3+
import * as AlertDialog from '$comp/ui/alert-dialog';
4+
import * as Form from '$comp/ui/form';
5+
import { Textarea } from '$comp/ui/textarea';
6+
import { defaults, superForm } from 'sveltekit-superforms';
7+
import { classvalidatorClient } from 'sveltekit-superforms/adapters';
8+
9+
import { UpdateToken } from '../../models';
10+
11+
interface Props {
12+
notes?: string;
13+
open: boolean;
14+
save: (notes?: string) => Promise<void>;
15+
}
16+
17+
let { notes, open = $bindable(), save }: Props = $props();
18+
19+
var defaultToken = new UpdateToken();
20+
defaultToken.notes = notes;
21+
defaultToken.is_disabled = false;
22+
23+
const form = superForm(defaults(defaultToken, classvalidatorClient(UpdateToken)), {
24+
dataType: 'json',
25+
async onUpdate({ form }) {
26+
if (!form.valid) {
27+
return;
28+
}
29+
30+
await save(form.data.notes?.trim() ?? undefined);
31+
open = false;
32+
},
33+
SPA: true,
34+
validators: classvalidatorClient(UpdateToken)
35+
});
36+
37+
const { enhance, form: formData } = form;
38+
</script>
39+
40+
<AlertDialog.Root bind:open>
41+
<AlertDialog.Content class="sm:max-w-[425px]">
42+
<form method="POST" use:enhance>
43+
<AlertDialog.Header>
44+
<AlertDialog.Title>API Key Notes</AlertDialog.Title>
45+
</AlertDialog.Header>
46+
47+
<P class="pb-4">
48+
<Form.Field {form} name="notes">
49+
<Form.Control>
50+
{#snippet children({ props })}
51+
<Form.Label>Notes</Form.Label>
52+
<Textarea {...props} bind:value={$formData.notes} placeholder="Please enter notes" autocomplete="off" />
53+
{/snippet}
54+
</Form.Control>
55+
<Form.Description />
56+
<Form.FieldErrors />
57+
</Form.Field>
58+
</P>
59+
60+
<AlertDialog.Footer>
61+
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
62+
<AlertDialog.Action>Save Notes</AlertDialog.Action>
63+
</AlertDialog.Footer>
64+
</form>
65+
</AlertDialog.Content>
66+
</AlertDialog.Root>

0 commit comments

Comments
 (0)