+ {#if error}
+
+ {errorMessages[error] || errorMessages.default}
+
+
+
Back to Settings
+ {:else}
+
+ Linking your Discord account...
+ {/if}
+
diff --git a/src/routes/(pages)/wave/(flows)/link-discord/callback/+page.ts b/src/routes/(pages)/wave/(flows)/link-discord/callback/+page.ts
new file mode 100644
index 000000000..b765efd89
--- /dev/null
+++ b/src/routes/(pages)/wave/(flows)/link-discord/callback/+page.ts
@@ -0,0 +1,49 @@
+import { error } from '@sveltejs/kit';
+import z from 'zod';
+import isSafePath from '$lib/utils/safe-path';
+
+export const load = async ({ url }) => {
+ const errorParam = url.searchParams.get('error');
+
+ if (errorParam === 'access_denied') {
+ return {
+ error: 'cancelled',
+ returnUrl: '/wave/settings/identity-and-payments',
+ };
+ }
+
+ let decodedState: string | null = null;
+ let stateJson: unknown = null;
+
+ try {
+ const stateParam = url.searchParams.get('state');
+ if (stateParam) {
+ decodedState = atob(stateParam);
+ stateJson = JSON.parse(decodedState);
+ }
+ } catch {
+ throw error(400, 'Invalid state parameter encoding');
+ }
+
+ const parsedState = z
+ .object({
+ returnUrl: z.string().optional().nullable(),
+ })
+ .safeParse(stateJson);
+
+ if (!parsedState.success) {
+ throw error(400, 'Invalid state parameter');
+ }
+
+ const { returnUrl } = parsedState.data;
+
+ // Validate returnUrl to prevent open redirect attacks
+ const safeReturnUrl =
+ returnUrl && isSafePath(returnUrl) ? returnUrl : '/wave/settings/identity-and-payments';
+
+ return {
+ returnUrl: safeReturnUrl,
+ };
+};
+
+export const ssr = false;
diff --git a/src/routes/(pages)/wave/(flows)/link-discord/callback/perform-linking.ts b/src/routes/(pages)/wave/(flows)/link-discord/callback/perform-linking.ts
new file mode 100644
index 000000000..79b18d41b
--- /dev/null
+++ b/src/routes/(pages)/wave/(flows)/link-discord/callback/perform-linking.ts
@@ -0,0 +1,48 @@
+import { redeemDiscordLink } from '$lib/utils/wave/discord';
+
+class LinkingError extends Error {
+ constructor(
+ message: string,
+ public code: string,
+ ) {
+ super(message);
+ this.name = 'LinkingError';
+ }
+}
+
+export default async function performLinking(url: URL) {
+ const code = url.searchParams.get('code');
+ const state = url.searchParams.get('state');
+ const errorParam = url.searchParams.get('error');
+
+ if (errorParam === 'access_denied') {
+ throw new LinkingError('Discord authorization was cancelled.', 'cancelled');
+ }
+
+ if (!code || !state) {
+ throw new LinkingError('Missing code or state in callback URL', 'invalid');
+ }
+
+ try {
+ const { linkedAccount } = await redeemDiscordLink(code, state);
+ return { linkedAccount };
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ if (
+ errorMessage.includes('409') ||
+ errorMessage.includes('already linked to another Wave account')
+ ) {
+ throw new LinkingError(
+ 'This Discord account is already linked to a different Drips Wave account.',
+ 'conflict',
+ );
+ }
+
+ if (errorMessage.includes('400')) {
+ throw new LinkingError('The link request expired. Please try again.', 'expired');
+ }
+
+ throw new LinkingError('Failed to link Discord. Please try again.', 'default');
+ }
+}
diff --git a/src/routes/(pages)/wave/(flows)/link-discord/callback/success/+page.svelte b/src/routes/(pages)/wave/(flows)/link-discord/callback/success/+page.svelte
new file mode 100644
index 000000000..1bcb15024
--- /dev/null
+++ b/src/routes/(pages)/wave/(flows)/link-discord/callback/success/+page.svelte
@@ -0,0 +1,26 @@
+
+
+