Skip to content

Commit 17f9a1e

Browse files
committed
next: Added ability to link slack via oauth
1 parent 3981788 commit 17f9a1e

File tree

4 files changed

+194
-42
lines changed

4 files changed

+194
-42
lines changed

src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ import { PersistedState } from 'runed';
66

77
import type { Login, TokenResult } from './models';
88

9+
export interface OAuthLoginOptions extends OAuthPopupOptions {
10+
redirectUrl?: string;
11+
}
12+
13+
export interface OAuthPopupOptions {
14+
authUrl: string;
15+
clientId: string;
16+
extraParams?: Record<string, string>;
17+
popupOptions?: { height: number; width: number };
18+
provider: SupportedOAuthProviders;
19+
scope: string;
20+
}
21+
22+
export interface OAuthResponseData {
23+
code: string;
24+
state: string;
25+
}
26+
27+
export type SupportedOAuthProviders = 'facebook' | 'github' | 'google' | 'live' | 'slack';
28+
929
const authSerializer = {
1030
deserialize: (value: null | string): null | string => {
1131
if (value === '') {
@@ -30,6 +50,7 @@ export const facebookClientId = env.PUBLIC_FACEBOOK_APPID;
3050
export const gitHubClientId = env.PUBLIC_GITHUB_APPID;
3151
export const googleClientId = env.PUBLIC_GOOGLE_APPID;
3252
export const microsoftClientId = env.PUBLIC_MICROSOFT_APPID;
53+
export const slackClientId = env.PUBLIC_SLACK_APPID;
3354
export const enableOAuthLogin = facebookClientId || gitHubClientId || googleClientId || microsoftClientId;
3455

3556
export async function facebookLogin(redirectUrl?: string) {
@@ -128,15 +149,43 @@ export async function logout() {
128149
accessToken.current = null;
129150
}
130151

131-
async function oauthLogin(options: {
132-
authUrl: string;
133-
clientId: string;
134-
extraParams?: Record<string, string>;
135-
popupOptions?: { height: number; width: number };
136-
provider: string;
137-
redirectUrl?: string;
138-
scope: string;
139-
}) {
152+
export async function slackOAuthLogin(): Promise<string> {
153+
if (!slackClientId) {
154+
throw new Error('Slack client id not set');
155+
}
156+
157+
const data = await openOAuthPopup({
158+
authUrl: 'https://slack.com/oauth/authorize',
159+
clientId: slackClientId,
160+
extraParams: {
161+
state: encodeURIComponent(Math.random().toString(36).substring(2))
162+
},
163+
popupOptions: { height: 630, width: 580 },
164+
provider: 'slack',
165+
scope: 'incoming-webhook'
166+
});
167+
168+
return data.code;
169+
}
170+
171+
async function oauthLogin(options: OAuthLoginOptions) {
172+
const data = await openOAuthPopup(options);
173+
174+
const client = useFetchClient();
175+
const response = await client.postJSON<TokenResult>(`auth/${options.provider}`, {
176+
clientId: options.clientId,
177+
code: data.code,
178+
redirectUri: window.location.origin,
179+
state: data.state
180+
});
181+
182+
if (response.ok && response.data?.token) {
183+
accessToken.current = response.data.token;
184+
await goto(options.redirectUrl || '/');
185+
}
186+
}
187+
188+
async function openOAuthPopup(options: OAuthPopupOptions): Promise<OAuthResponseData> {
140189
const width = options.popupOptions?.width || 500;
141190
const height = options.popupOptions?.height || 500;
142191
const features = {
@@ -158,25 +207,19 @@ async function oauthLogin(options: {
158207
);
159208

160209
const url = `${options.authUrl}?${new URLSearchParams(params).toString()}`;
161-
162210
const popup = window.open(url, options.provider, stringifyOptions(features));
163-
popup?.focus();
164-
165-
const data = await waitForUrl(popup!, redirectUrl);
166-
if (options.extraParams?.state && data.state !== options.extraParams.state) throw new Error('Invalid state');
211+
if (!popup) {
212+
throw new Error('Failed to open popup window');
213+
}
167214

168-
const client = useFetchClient();
169-
const response = await client.postJSON<TokenResult>(`auth/${options.provider}`, {
170-
clientId: options.clientId,
171-
code: data.code,
172-
redirectUri: redirectUrl,
173-
state: data.state
174-
});
215+
popup.focus();
175216

176-
if (response.ok && response.data?.token) {
177-
accessToken.current = response.data.token;
178-
await goto(options.redirectUrl || '/');
217+
const data = await waitForUrl(popup!, redirectUrl);
218+
if (options.extraParams?.state && data.state !== options.extraParams.state) {
219+
throw new Error('Invalid state');
179220
}
221+
222+
return data;
180223
}
181224

182225
function stringifyOptions(options: object): string {
@@ -189,7 +232,7 @@ function stringifyOptions(options: object): string {
189232
return parts.join(',');
190233
}
191234

192-
function waitForUrl(popup: Window, redirectUri: string): Promise<{ code: string; state: string }> {
235+
function waitForUrl(popup: Window, redirectUri: string): Promise<OAuthResponseData> {
193236
return new Promise((resolve, reject) => {
194237
const polling = setInterval(() => {
195238
if (!popup || popup.closed || popup.closed === undefined) {
@@ -210,7 +253,7 @@ function waitForUrl(popup: Window, redirectUri: string): Promise<{ code: string;
210253
if ('error' in params && (params as { error: string }).error) {
211254
reject(new Error((params as { error: string }).error));
212255
} else {
213-
resolve(params);
256+
resolve(params as OAuthResponseData);
214257
}
215258
} else {
216259
reject(

src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ export const queryKeys = {
2727
deleteConfig: (id: string | undefined) => [...queryKeys.id(id), 'delete-config'] as const,
2828
deleteProject: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const,
2929
deletePromotedTab: (id: string | undefined) => [...queryKeys.id(id), 'demote-tab'] as const,
30+
deleteSlack: (id: string | undefined) => [...queryKeys.id(id), 'delete-slack'] as const,
3031
id: (id: string | undefined) => [...queryKeys.type, id] as const,
3132
ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const,
3233
organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const,
3334
postConfig: (id: string | undefined) => [...queryKeys.id(id), 'post-config'] as const,
35+
postProject: () => [...queryKeys.type, 'post-project'] as const,
3436
postPromotedTab: (id: string | undefined) => [...queryKeys.id(id), 'promote-tab'] as const,
37+
postSlack: (id: string | undefined) => [...queryKeys.id(id), 'post-slack'] as const,
3538
resetData: (id: string | undefined) => [...queryKeys.id(id), 'reset-data'] as const,
3639
type: ['Project'] as const
3740
};
@@ -61,6 +64,12 @@ export interface DeletePromotedTabRequest {
6164
};
6265
}
6366

67+
export interface DeleteSlackRequest {
68+
route: {
69+
id: string;
70+
};
71+
}
72+
6473
export interface GetOrganizationProjectsParams {
6574
filter?: string;
6675
limit?: number;
@@ -106,6 +115,16 @@ export interface PostPromotedTabParams {
106115
}
107116

108117
export interface PostPromotedTabRequest {
118+
route: {
119+
id: string;
120+
};
121+
}
122+
123+
export interface PostSlackParams {
124+
code: string;
125+
}
126+
127+
export interface PostSlackRequest {
109128
route: {
110129
id: string | undefined;
111130
};
@@ -194,6 +213,24 @@ export function deletePromotedTab(request: DeletePromotedTabRequest) {
194213
}));
195214
}
196215

216+
export function deleteSlack(request: DeleteSlackRequest) {
217+
const queryClient = useQueryClient();
218+
219+
return createMutation<boolean, ProblemDetails, void>(() => ({
220+
enabled: () => !!accessToken.current && request.route.id,
221+
mutationFn: async () => {
222+
const client = useFetchClient();
223+
const response = await client.delete(`projects/${request.route.id}/slack`);
224+
225+
return response.ok;
226+
},
227+
mutationKey: queryKeys.deleteSlack(request.route.id),
228+
onSuccess: () => {
229+
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) });
230+
}
231+
}));
232+
}
233+
197234
export function getOrganizationProjectsQuery(request: GetOrganizationProjectsRequest) {
198235
const queryClient = useQueryClient();
199236

@@ -261,6 +298,7 @@ export function postProject() {
261298
const response = await client.postJSON<ViewProject>('projects', project);
262299
return response.data!;
263300
},
301+
mutationKey: queryKeys.postProject(),
264302
onSuccess: () => {
265303
queryClient.invalidateQueries({ queryKey: queryKeys.type });
266304
}
@@ -315,6 +353,24 @@ export function postPromotedTab(request: PostPromotedTabRequest) {
315353
}));
316354
}
317355

356+
export function postSlack(request: PostSlackRequest) {
357+
const queryClient = useQueryClient();
358+
359+
return createMutation<boolean, ProblemDetails, PostSlackParams>(() => ({
360+
enabled: () => !!accessToken.current && !!request.route.id,
361+
mutationFn: async (params: PostSlackParams) => {
362+
const client = useFetchClient();
363+
const response = await client.post(`projects/${request.route.id}/slack`, { code: params.code });
364+
365+
return response.ok;
366+
},
367+
mutationKey: queryKeys.postSlack(request.route.id),
368+
onSuccess: () => {
369+
queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) });
370+
}
371+
}));
372+
}
373+
318374
export function resetData(request: ResetDataRequest) {
319375
return createMutation<void, ProblemDetails, void>(() => ({
320376
enabled: () => !!accessToken.current && !!request.route.id,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
open: boolean;
7+
remove: () => Promise<void>;
8+
}
9+
10+
let { open = $bindable(), remove }: Props = $props();
11+
12+
async function onSubmit() {
13+
await remove();
14+
open = false;
15+
}
16+
</script>
17+
18+
<AlertDialog.Root bind:open>
19+
<AlertDialog.Content>
20+
<AlertDialog.Header>
21+
<AlertDialog.Title>Remove Slack Integration</AlertDialog.Title>
22+
<AlertDialog.Description>
23+
Are you sure you want to remove the Slack integration from this project? This will disable all Slack notifications for this project.
24+
</AlertDialog.Description>
25+
</AlertDialog.Header>
26+
<AlertDialog.Footer>
27+
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
28+
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={onSubmit}>Remove Slack</AlertDialog.Action>
29+
</AlertDialog.Footer>
30+
</AlertDialog.Content>
31+
</AlertDialog.Root>

src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import { Button } from '$comp/ui/button';
66
import { Separator } from '$comp/ui/separator';
77
import { env } from '$env/dynamic/public';
8+
import { slackOAuthLogin } from '$features/auth/index.svelte';
89
import { organization } from '$features/organizations/context.svelte';
9-
import { getProjectQuery } from '$features/projects/api.svelte';
10+
import { deleteSlack, getProjectQuery, postSlack } from '$features/projects/api.svelte';
11+
import RemoveSlackDialog from '$features/projects/components/dialogs/remove-slack-dialog.svelte';
1012
import { DEFAULT_LIMIT } from '$features/shared/api/api.svelte';
1113
import { postWebhook } from '$features/webhooks/api.svelte';
1214
import { getProjectWebhooksQuery } from '$features/webhooks/api.svelte';
@@ -23,6 +25,7 @@
2325
import { toast } from 'svelte-sonner';
2426
2527
let showAddWebhookDialog = $state(false);
28+
let showRemoveSlackDialog = $state(false);
2629
const projectId = page.params.projectId || '';
2730
const projectResponse = getProjectQuery({
2831
route: {
@@ -36,6 +39,22 @@
3639
const hasSlackIntegration = $derived(projectResponse.data?.has_slack_integration ?? false);
3740
const newWebhook = postWebhook();
3841
42+
const addSlackMutation = postSlack({
43+
route: {
44+
get id() {
45+
return projectId;
46+
}
47+
}
48+
});
49+
50+
const removeSlackMutation = deleteSlack({
51+
route: {
52+
get id() {
53+
return projectId;
54+
}
55+
}
56+
});
57+
3958
async function addWebhook(webhook: NewWebhook) {
4059
try {
4160
await newWebhook.mutateAsync(webhook);
@@ -46,23 +65,22 @@
4665
}
4766
4867
async function addSlack() {
49-
/*
50-
51-
.confirmDanger(
52-
translateService.T("Are you sure you want to remove slack support?"),
53-
translateService.T("Remove Slack")
54-
)
55-
56-
function onSuccess(response) {
57-
return Restangular.one("projects", id).post("slack", null, { code: response.code });
58-
}
59-
60-
return $auth.link("slack").then(onSuccess);
61-
*/
68+
try {
69+
const code = await slackOAuthLogin();
70+
await addSlackMutation.mutateAsync({ code });
71+
toast.success('Successfully connected Slack integration.');
72+
} catch {
73+
toast.error('Error connecting Slack integration. Please try again.');
74+
}
6275
}
6376
6477
async function removeSlack() {
65-
//return Restangular.one("projects", id).one("slack").remove();
78+
try {
79+
await removeSlackMutation.mutateAsync();
80+
toast.success('Successfully removed Slack integration.');
81+
} catch {
82+
toast.error('Error removing Slack integration. Please try again.');
83+
}
6684
}
6785
6886
const DEFAULT_PARAMS = {
@@ -126,7 +144,7 @@
126144
>
127145

128146
{#if hasSlackIntegration}
129-
<Button onclick={removeSlack}><img class="text- mr-2 size-4" alt="Slack" src={Slack} /> Connect Slack</Button>
147+
<Button onclick={() => (showRemoveSlackDialog = true)}><img class="text- mr-2 size-4" alt="Slack" src={Slack} /> Remove Slack</Button>
130148
{:else}
131149
<Button onclick={addSlack}><img class="text- mr-2 size-4" alt="Slack" src={Slack} /> Connect Slack</Button>
132150
{/if}
@@ -159,3 +177,7 @@
159177
{#if showAddWebhookDialog && organization.current}
160178
<AddWebhookDialog bind:open={showAddWebhookDialog} save={addWebhook} {projectId} organizationId={organization.current} />
161179
{/if}
180+
181+
{#if showRemoveSlackDialog}
182+
<RemoveSlackDialog bind:open={showRemoveSlackDialog} remove={removeSlack} />
183+
{/if}

0 commit comments

Comments
 (0)