Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
af72e34
Next: Adds organization management features
niemyjski Jul 7, 2025
069cdc4
Merge branch 'main' into feature/next-organization-management
niemyjski Jul 8, 2025
c649685
Handles unauthorized requests globally
niemyjski Jul 10, 2025
5e4fbb0
Updated deps
niemyjski Jul 10, 2025
b9f345c
Exposes time property on query parameters
niemyjski Jul 10, 2025
aa98121
Added beast mode prompt
niemyjski Jul 13, 2025
1ee9825
Updates to manage organization pages and better error handling
niemyjski Jul 13, 2025
f112950
reverted class-validator as it was causing issues with validation.
niemyjski Jul 13, 2025
0948790
Work around for client side validation erroring.
niemyjski Jul 13, 2025
c78227d
Updated deps
niemyjski Jul 13, 2025
635da9a
Allow you to switch organizations when viewing an organization.
niemyjski Jul 14, 2025
164e322
Updates issue links for better navigation
niemyjski Jul 14, 2025
cdc3490
Refactors usage page and fixes invite dialog
niemyjski Jul 14, 2025
746d60c
Fixed logout page width
niemyjski Jul 14, 2025
e115dc4
Disables sorting on organization and project tables
niemyjski Jul 14, 2025
f7dca93
Formats date and time values consistently.
niemyjski Jul 14, 2025
3e178c2
Adds GlobalUser component for role-based rendering
niemyjski Jul 14, 2025
91e08d7
Use global layout for payment page.
niemyjski Jul 14, 2025
6bd8df0
ran format
niemyjski Jul 14, 2025
c7bee97
Apply suggestion from @Copilot
niemyjski Jul 14, 2025
bb055ba
Handles FetchClientResponse in query onSuccess
niemyjski Jul 14, 2025
4c42c30
Uses number formatter for event limit
niemyjski Jul 14, 2025
62eea4f
Improves email verification workflow
niemyjski Jul 14, 2025
b5cd6ba
Updates dependencies
niemyjski Jul 15, 2025
2eb6d0f
Sets current organization on page load
niemyjski Jul 15, 2025
9489d92
Guards payment page from unauthorized access since we are not using t…
niemyjski Jul 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import type { WebSocketMessageValue } from '$features/websockets/models';
import type { QueryClient } from '@tanstack/svelte-query';

import { accessToken } from '$features/auth/index.svelte';
import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';

import type { ViewOrganization } from './models';
import type { Invoice, InvoiceGridModel, NewOrganization, ViewOrganization } from './models';

export async function invalidateOrganizationQueries(queryClient: QueryClient, message: WebSocketMessageValue<'OrganizationChanged'>) {
const { id } = message;
Expand All @@ -21,11 +21,46 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me
}

export const queryKeys = {
deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const,
id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)),
ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const,
invoice: (id: string | undefined) => [...queryKeys.type, 'invoice', id] as const,
invoices: (organizationId: string | undefined) => [...queryKeys.type, organizationId, 'invoices'] as const,
list: (mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, 'list', { mode }] as const) : ([...queryKeys.type, 'list'] as const)),
postOrganization: () => [...queryKeys.type, 'post-organization'] as const,
type: ['Organization'] as const
};

export interface AddOrganizationUserRequest {
route: {
email: string;
organizationId: string;
};
}

export interface DeleteOrganizationRequest {
route: {
ids: string[];
};
}

export interface GetInvoiceRequest {
route: {
id: string;
};
}

export interface GetInvoicesRequest {
params?: {
after?: string;
before?: string;
limit?: number;
};
route: {
organizationId: string;
};
}

export interface GetOrganizationRequest {
params?: {
mode: 'stats' | undefined;
Expand All @@ -35,12 +70,107 @@ export interface GetOrganizationRequest {
};
}

export type GetOrganizationsMode = 'stats' | null;

export interface GetOrganizationsParams {
mode: GetOrganizationsMode;
}

export interface GetOrganizationsRequest {
params?: {
mode: 'stats' | undefined;
params?: GetOrganizationsParams;
}

export interface RemoveOrganizationUserRequest {
route: {
email: string;
organizationId: string;
};
}

export interface UpdateOrganizationRequest {
route: {
id: string;
};
}

export function addOrganizationUser(request: AddOrganizationUserRequest) {
const queryClient = useQueryClient();
return createMutation<{ emailAddress: string }, ProblemDetails, void>(() => ({
enabled: () => !!accessToken.current && !!request.route.organizationId && !!request.route.email,
mutationFn: async () => {
const client = useFetchClient();
const response = await client.postJSON<{ emailAddress: string }>(
`organizations/${request.route.organizationId}/users/${encodeURIComponent(request.route.email)}`
);
return response.data!;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) });
queryClient.invalidateQueries({ queryKey: ['User', 'organization', request.route.organizationId] });
}
}));
}

export function deleteOrganization(request: DeleteOrganizationRequest) {
const queryClient = useQueryClient();

return createMutation<FetchClientResponse<unknown>, ProblemDetails, void>(() => ({
enabled: () => !!accessToken.current && !!request.route.ids?.length,
mutationFn: async () => {
const client = useFetchClient();
const response = await client.delete(`organizations/${request.route.ids?.join(',')}`, {
expectedStatusCodes: [202]
});

return response;
},
mutationKey: queryKeys.deleteOrganization(request.route.ids),
onError: () => {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id, undefined) }));
},
onSuccess: () => {
request.route.ids?.forEach((id) => queryClient.invalidateQueries({ queryKey: queryKeys.id(id, undefined) }));
}
}));
}

export function getInvoiceQuery(request: GetInvoiceRequest) {
const queryClient = useQueryClient();

return createQuery<Invoice, ProblemDetails>(() => ({
enabled: () => !!accessToken.current && !!request.route.id,
queryClient,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const client = useFetchClient();
const response = await client.getJSON<Invoice>(`organizations/invoice/${request.route.id}`, {
signal
});

return response.data!;
},
queryKey: queryKeys.invoice(request.route.id)
}));
}

export function getInvoicesQuery(request: GetInvoicesRequest) {
const queryClient = useQueryClient();

return createQuery<FetchClientResponse<InvoiceGridModel[]>, ProblemDetails>(() => ({
enabled: () => !!accessToken.current && !!request.route.organizationId,
queryClient,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const client = useFetchClient();
const response = await client.getJSON<InvoiceGridModel[]>(`organizations/${request.route.organizationId}/invoices`, {
params: { ...request.params },
signal
});

return response;
},
queryKey: queryKeys.invoices(request.route.organizationId)
}));
}

export function getOrganizationQuery(request: GetOrganizationRequest) {
const queryClient = useQueryClient();

Expand Down Expand Up @@ -69,7 +199,7 @@ export function getOrganizationQuery(request: GetOrganizationRequest) {
export function getOrganizationsQuery(request: GetOrganizationsRequest) {
const queryClient = useQueryClient();

return createQuery<ViewOrganization[], ProblemDetails>(() => ({
return createQuery<FetchClientResponse<ViewOrganization[]>, ProblemDetails>(() => ({
enabled: () => !!accessToken.current,
onSuccess: (data: ViewOrganization[]) => {
data.forEach((organization) => {
Expand All @@ -84,12 +214,64 @@ export function getOrganizationsQuery(request: GetOrganizationsRequest) {
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const client = useFetchClient();
const response = await client.getJSON<ViewOrganization[]>('organizations', {
params: request.params,
params: { ...request.params },
signal
});

return response.data!;
return response;
},
queryKey: [...queryKeys.list(request.params?.mode ?? undefined), { params: request.params }]
}));
}

export function postOrganization() {
const queryClient = useQueryClient();

return createMutation<ViewOrganization, ProblemDetails, NewOrganization>(() => ({
enabled: () => !!accessToken.current,
mutationFn: async (organization: NewOrganization) => {
const client = useFetchClient();
const response = await client.postJSON<ViewOrganization>('organizations', organization);
return response.data!;
},
mutationKey: queryKeys.postOrganization(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.type });
}
}));
}

export function removeOrganizationUser(request: RemoveOrganizationUserRequest) {
const queryClient = useQueryClient();
return createMutation<void, ProblemDetails, void>(() => ({
enabled: () => !!accessToken.current && !!request.route.organizationId && !!request.route.email,
mutationFn: async () => {
const client = useFetchClient();
await client.deleteJSON<void>(`organizations/${request.route.organizationId}/users/${encodeURIComponent(request.route.email)}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.organizationId, undefined) });
queryClient.invalidateQueries({ queryKey: ['User', 'organization', request.route.organizationId] });
}
}));
}

export function updateOrganization(request: UpdateOrganizationRequest) {
const queryClient = useQueryClient();

return createMutation<ViewOrganization, ProblemDetails, NewOrganization>(() => ({
enabled: () => !!accessToken.current && !!request.route.id,
mutationFn: async (data: NewOrganization) => {
const client = useFetchClient();
const response = await client.patchJSON<ViewOrganization>(`organizations/${request.route.id}`, data);
return response.data!;
},
onError: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) });
},
onSuccess: (organization: ViewOrganization) => {
queryClient.setQueryData(queryKeys.id(request.route.id, 'stats'), organization);
queryClient.setQueryData(queryKeys.id(request.route.id, undefined), organization);
}
}));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import * as AlertDialog from '$comp/ui/alert-dialog';
import { buttonVariants } from '$comp/ui/button';

interface Props {
leave: () => Promise<void>;
name: string;
open: boolean;
}

let { leave, name, open = $bindable(false) }: Props = $props();

async function onSubmit() {
await leave();
open = false;
}
</script>

<AlertDialog.Root bind:open>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Leave Organization</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to leave "<span class="inline-block max-w-[200px] truncate align-bottom" title={name}>{name}</span>"?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={onSubmit}>Leave Organization</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import * as AlertDialog from '$comp/ui/alert-dialog';
import { buttonVariants } from '$comp/ui/button';

interface Props {
leave: () => Promise<void>;
name: string;
open: boolean;
}

let { leave, name, open = $bindable(false) }: Props = $props();

async function onSubmit() {
await leave();
open = false;
}
</script>

<AlertDialog.Root bind:open>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Leave Organization</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to leave "<span class="inline-block max-w-[200px] truncate align-bottom" title={name}>{name}</span>"?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={onSubmit}>Leave Organization</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import * as AlertDialog from '$comp/ui/alert-dialog';
import { buttonVariants } from '$comp/ui/button';

interface Props {
name: string;
open: boolean;
remove: () => Promise<void>;
}

let { name, open = $bindable(false), remove }: Props = $props();

async function onSubmit() {
await remove();
open = false;
}
</script>

<AlertDialog.Root bind:open>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Organization</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete "<span class="inline-block max-w-[200px] truncate align-bottom" title={name}>{name}</span>"?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={onSubmit}>Delete Organization</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
Loading