Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions src/lib/components/icons/Discord-Spot.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script lang="ts">
interface Props {
size: number;
}

let { size }: Props = $props();
</script>

<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_19610_606)">
<path
d="M30.4648 7.55664C33.4285 8.10104 36.2672 9.01287 38.9326 10.2471C43.7637 17.5095 46.1721 25.6745 45.333 35.082C41.7557 37.7261 38.2845 39.3731 34.8682 40.4717C34.1753 39.4937 33.5456 38.4661 32.9844 37.3955C34.1381 36.9257 35.2505 36.3682 36.3086 35.7305L36.9443 35.3477L36.3496 34.9023C36.0455 34.6746 35.7447 34.4359 35.4502 34.1904L35.2041 33.9854L34.916 34.1221C27.8989 37.4438 20.1885 37.4449 13.082 34.1211L12.792 33.9854L12.5479 34.1924C12.2583 34.4366 11.9585 34.6736 11.6523 34.9004L11.0518 35.3457L11.6924 35.7305C12.7467 36.3646 13.855 36.9219 15.0078 37.3916C14.4465 38.4632 13.8156 39.4892 13.124 40.4678C9.71115 39.3691 6.24231 37.723 2.66504 35.082C1.94777 26.9515 3.48698 18.7133 9.0498 10.2529C11.7159 9.01517 14.5589 8.10122 17.5264 7.55664C17.8709 8.20086 18.247 8.97517 18.5098 9.60645L18.6602 9.96777L19.0469 9.9082C22.3184 9.40699 25.6214 9.40662 28.957 9.9082L29.3438 9.9668L29.4932 9.60645C29.7568 8.97337 30.1255 8.19941 30.4648 7.55664Z"
fill="var(--color-primary)"
stroke="var(--color-foreground)"
/>
<path
d="M17.0544 21.2063C18.9339 21.2065 20.544 22.951 20.5095 25.2014V25.2102C20.5127 27.4693 18.9285 29.2089 17.0544 29.2092C15.2159 29.2092 13.5984 27.4664 13.5984 25.2092C13.5985 22.9483 15.1813 21.2063 17.0544 21.2063ZM31.6726 21.2063C33.552 21.2065 35.1621 22.951 35.1277 25.2014V25.2092C35.1277 27.4696 33.5461 29.209 31.6726 29.2092C29.8341 29.2092 28.2166 27.4664 28.2166 25.2092C28.2167 22.9483 29.7994 21.2063 31.6726 21.2063Z"
fill="var(--color-background)"
stroke="var(--color-foreground)"
/>
</g>
<defs>
<clipPath id="clip0_19610_606">
<rect width="48" height="48" fill="var(--color-background)" />
</clipPath>
</defs>
</svg>
48 changes: 48 additions & 0 deletions src/lib/utils/wave/discord.ts
Original file line number Diff line number Diff line change
@@ -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',
});
}
13 changes: 13 additions & 0 deletions src/lib/utils/wave/types/linked-accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import z from 'zod';

export const linkedAccountProviderSchema = z.enum(['discord']);
export type LinkedAccountProvider = z.infer<typeof linkedAccountProviderSchema>;

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<typeof linkedAccountDtoSchema>;
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import Button from '$lib/components/button/button.svelte';
import Divider from '$lib/components/divider/divider.svelte';
import HeadMeta from '$lib/components/head-meta/head-meta.svelte';
import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
import CrossCircle from '$lib/components/icons/CrossCircle.svelte';
import Discord from '$lib/components/icons/Discord.svelte';
import ExclamationCircle from '$lib/components/icons/ExclamationCircle.svelte';
import Setting from '$lib/components/setting/setting.svelte';
import { unlinkDiscordAccount } from '$lib/utils/wave/discord';
import doWithConfirmationModal from '$lib/utils/do-with-confirmation-modal';
import doWithErrorModal from '$lib/utils/do-with-error-modal';

let { data } = $props();
let { user, kycStatus } = $derived(data);
let { user, kycStatus, linkedAccounts } = $derived(data);

let discordAccount = $derived(linkedAccounts.find((a) => a.provider === 'discord'));

let unlinking = $state(false);

async function handleUnlinkDiscord() {
await doWithConfirmationModal(
'Your Discord roles will be removed. You can re-link at any time.',
() =>
doWithErrorModal(async () => {
unlinking = true;
try {
await unlinkDiscordAccount();
await invalidate('wave:linked-accounts');
} finally {
unlinking = false;
}
}),
);
}
</script>

<HeadMeta title="Identity & Payments | Settings | Wave" />
Expand Down Expand Up @@ -83,6 +108,41 @@
<span class="typo-text">{user.email}</span>
</Setting>

<Divider />
<h5>Connected Accounts</h5>

<Setting
title="Discord"
subtitle="Link your Discord account to receive roles based on your Wave activity."
>
{#if discordAccount}
<div class="linked-account">
{#if discordAccount.providerAvatarUrl}
<img
src={discordAccount.providerAvatarUrl}
alt="{discordAccount.providerUsername}'s Discord avatar"
class="discord-avatar"
/>
{:else}
<div class="discord-avatar placeholder">
<Discord />
</div>
{/if}
<div class="account-info">
<span class="username">{discordAccount.providerUsername}</span>
{#if discordAccount.providerDisplayName}
<span class="display-name">{discordAccount.providerDisplayName}</span>
{/if}
</div>
<Button variant="destructive" onclick={handleUnlinkDiscord} disabled={unlinking}>
{unlinking ? 'Unlinking...' : 'Unlink'}
</Button>
</div>
{:else}
<Button variant="primary" icon={Discord} href="/wave/link-discord">Link Discord</Button>
{/if}
</Setting>

<style>
.kyc-status {
display: flex;
Expand All @@ -103,4 +163,49 @@
flex-wrap: wrap;
align-items: center;
}

.linked-account {
display: flex;
align-items: center;
gap: 0.75rem;
background-color: var(--color-foreground-level-1);
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border-radius: 2rem 0 2rem 2rem;
}

.discord-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
}

.discord-avatar.placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-foreground-level-2);
}

.account-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}

.account-info .username {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.account-info .display-name {
font-size: 0.875rem;
color: var(--color-foreground-level-5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading
Loading