diff --git a/src/lib/components/icons/Discord-Spot.svelte b/src/lib/components/icons/Discord-Spot.svelte new file mode 100644 index 000000000..71ac32d5d --- /dev/null +++ b/src/lib/components/icons/Discord-Spot.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/src/lib/utils/wave/discord.ts b/src/lib/utils/wave/discord.ts new file mode 100644 index 000000000..7a8f8db09 --- /dev/null +++ b/src/lib/utils/wave/discord.ts @@ -0,0 +1,48 @@ +import z from 'zod'; +import { authenticatedCall, call } from './call'; +import { linkedAccountDtoSchema } from './types/linked-accounts'; +import parseRes from './utils/parse-res'; + +export async function getDiscordLinkUrl(returnUrl?: string) { + const params = returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''; + return parseRes( + z.object({ + url: z.url(), + }), + await authenticatedCall(undefined, `/api/auth/oauth/discord/link${params}`), + ); +} + +export async function redeemDiscordLink(code: string, state: string) { + const res = await call('/api/auth/oauth/discord/redeem-link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code, state }), + credentials: 'include', + }); + + const data = z + .object({ + linkedAccount: linkedAccountDtoSchema, + }) + .parse(res); + + return data; +} + +export async function getLinkedAccounts(f = fetch) { + return parseRes( + z.object({ + linkedAccounts: z.array(linkedAccountDtoSchema), + }), + await authenticatedCall(f, '/api/user/linked-accounts'), + ); +} + +export async function unlinkDiscordAccount(f = fetch) { + await authenticatedCall(f, '/api/user/linked-accounts/discord', { + method: 'DELETE', + }); +} diff --git a/src/lib/utils/wave/types/linked-accounts.ts b/src/lib/utils/wave/types/linked-accounts.ts new file mode 100644 index 000000000..2ca8e427e --- /dev/null +++ b/src/lib/utils/wave/types/linked-accounts.ts @@ -0,0 +1,13 @@ +import z from 'zod'; + +export const linkedAccountProviderSchema = z.enum(['discord']); +export type LinkedAccountProvider = z.infer; + +export const linkedAccountDtoSchema = z.object({ + provider: linkedAccountProviderSchema, + providerUsername: z.string(), + providerDisplayName: z.string().nullable(), + providerAvatarUrl: z.url().nullable(), + linkedAt: z.coerce.date(), +}); +export type LinkedAccountDto = z.infer; diff --git a/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.svelte b/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.svelte index 1fc7c6618..9316ba6f5 100644 --- a/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.svelte +++ b/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.svelte @@ -1,15 +1,40 @@ @@ -83,6 +108,41 @@ {user.email} + +
Connected Accounts
+ + + {#if discordAccount} + + {:else} + + {/if} + + diff --git a/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.ts b/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.ts index 276408be0..ba5261f40 100644 --- a/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.ts +++ b/src/routes/(pages)/wave/(base-layout)/settings/identity-and-payments/+page.ts @@ -1,17 +1,24 @@ +import { getLinkedAccounts } from '$lib/utils/wave/discord.js'; import { getKycStatus } from '$lib/utils/wave/kyc.js'; import { redirect } from '@sveltejs/kit'; -export const load = async ({ parent, url, fetch }) => { +export const load = async ({ parent, url, fetch, depends }) => { + depends('wave:linked-accounts'); + const { user } = await parent(); if (!user) { throw redirect(302, `/wave/login?backTo=${encodeURIComponent(url.pathname + url.search)}`); } - const kycStatus = await getKycStatus(fetch); + const [kycStatus, linkedAccountsResult] = await Promise.all([ + getKycStatus(fetch), + getLinkedAccounts(fetch).catch(() => ({ linkedAccounts: [] })), + ]); return { user, kycStatus, + linkedAccounts: linkedAccountsResult.linkedAccounts, }; }; diff --git a/src/routes/(pages)/wave/(flows)/link-discord/+page.svelte b/src/routes/(pages)/wave/(flows)/link-discord/+page.svelte new file mode 100644 index 000000000..d2b55eb9b --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/link-discord/+page.svelte @@ -0,0 +1,194 @@ + + + + +{#if data.discordAccount} + + + + + Your Discord roles are automatically updated based on your Wave activity. You have access to + the "Wave Contributor" role for earning points, and the "Wave Project Maintainer" role if you + have approved repositories. + + + {#snippet leftActions()} + + {/snippet} + + {#snippet actions()} + + {/snippet} + +{:else} + +
+
+ +

Automatic roles in the Drips Discord based on your Wave activity

+
+
+ +

For maintainers, automatic access to the maintainers-only channel

+
+
+ +

Stay up-to-date with Wave announcements on the Drips Discord

+
+ + + If you're not already a member of the Drips Discord server, linking will automatically add you to it. + +
+ + {#snippet leftActions()} + + {/snippet} + + {#snippet actions()} + + {/snippet} +
+{/if} + + diff --git a/src/routes/(pages)/wave/(flows)/link-discord/+page.ts b/src/routes/(pages)/wave/(flows)/link-discord/+page.ts new file mode 100644 index 000000000..c06ccb347 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/link-discord/+page.ts @@ -0,0 +1,19 @@ +import { getLinkedAccounts } from '$lib/utils/wave/discord.js'; +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ parent, url, fetch, depends }) => { + depends('wave:linked-accounts'); + + const { user } = await parent(); + + if (!user) { + throw redirect(302, `/wave/login?backTo=${encodeURIComponent(url.pathname)}`); + } + + const linkedAccountsResult = await getLinkedAccounts(fetch).catch(() => ({ linkedAccounts: [] })); + const discordAccount = linkedAccountsResult.linkedAccounts.find((a) => a.provider === 'discord'); + + return { + discordAccount: discordAccount ?? null, + }; +}; diff --git a/src/routes/(pages)/wave/(flows)/link-discord/callback/+page.svelte b/src/routes/(pages)/wave/(flows)/link-discord/callback/+page.svelte new file mode 100644 index 000000000..b0c2b8305 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/link-discord/callback/+page.svelte @@ -0,0 +1,64 @@ + + + + +
+ {#if error} +
+ {errorMessages[error] || errorMessages.default} +
+ + + {: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 @@ + + + + + + + Your Discord roles have been updated. You'll receive the "Wave Contributor" role for earning + points, and the "Wave Project Maintainer" role if you have approved repositories. + + + {#snippet actions()} + + {/snippet} +