Skip to content

Latest commit

Β 

History

History
510 lines (392 loc) Β· 12.2 KB

File metadata and controls

510 lines (392 loc) Β· 12.2 KB

Account Linking

Sigmagit supports linking user accounts to Discord, enabling notifications and bot integration.

Overview

The account linking system provides:

  • Secure Discord account linking via email verification
  • Token-based verification process
  • Support for linking/unlinking accounts
  • Verification state tracking
  • Integration with Discord bot commands

Database Schema

Discord Links Table

// packages/db/src/discord.ts
export const discordLinks = pgTable('discord_links', {
  id: uuid('id').defaultRandom().primaryKey(),
  discordId: text('discord_id').notNull().unique(),
  userId: uuid('user_id').references(() => users.id).notNull(),
  verified: boolean('verified').notNull().default(false),
  linkedAt: timestamp('linked_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

Link Tokens Table

export const linkTokens = pgTable('link_tokens', {
  id: uuid('id').defaultRandom().primaryKey(),
  token: text('token').notNull().unique(),
  discordId: text('discord_id').notNull(),
  sigmagitEmail: text('sigmagit_email').notNull(),
  expiresAt: timestamp('expires_at').notNull(),
  used: boolean('used').notNull().default(false),
  createdAt: timestamp('created_at').defaultNow(),
});

API Endpoints

Generate Link Token

Generate a verification token for Discord account linking:

// apps/api/src/routes/discord.ts
app.post('/api/discord/link/generate', async (c) => {
  const { discordId, sigmagitEmail } = await c.req.json();

  const token = await generateLinkToken(discordId, sigmagitEmail);

  await sendLinkEmail(sigmagitEmail, token);

  return c.json({ token: token.substring(0, 16) + '...' });
});

Verify Link Token

Verify a link token and complete the account linking:

app.post('/api/discord/link/verify', async (c) => {
  const { token, sigmagitUserId } = await c.req.json();

  const result = await verifyAndLinkAccount(token, sigmagitUserId);

  if (!result.success) {
    return c.json({ error: result.error }, 400);
  }

  return c.json({ user: result.user });
});

Unlink Account

Remove Discord account linking:

app.post('/api/discord/link/unlink', async (c) => {
  const { discordId } = await c.req.json();

  await db
    .delete(discordLinks)
    .where(eq(discordLinks.discordId, discordId));

  return c.json({ success: true });
});

Get Link Status

Check if a Discord account is linked:

app.get('/api/discord/link/status/:discordId', async (c) => {
  const discordId = c.req.param('discordId');

  const link = await db
    .select()
    .from(discordLinks)
    .where(eq(discordLinks.discordId, discordId))
    .leftJoin(users, eq(discordLinks.userId, users.id))
    .get();

  if (!link) {
    return c.json({ linked: false });
  }

  return c.json({
    linked: true,
    verified: link.discord_links.verified,
    user: link.users,
    linkedAt: link.discord_links.linkedAt,
  });
});

Token Generation and Validation

Generate Link Token

// apps/api/src/utils/linking.ts
import crypto from 'crypto';

export async function generateLinkToken(
  discordId: string,
  sigmagitEmail: string
): Promise<string> {
  const token = crypto.randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours

  await db.insert(linkTokens).values({
    token,
    discordId,
    sigmagitEmail,
    expiresAt,
  });

  return token;
}

Validate Link Token

export async function validateLinkToken(
  token: string
): Promise<{ valid: boolean; tokenData?: LinkToken }> {
  const linkToken = await db
    .select()
    .from(linkTokens)
    .where(eq(linkTokens.token, token))
    .get();

  if (!linkToken) {
    return { valid: false };
  }

  if (linkToken.used) {
    return { valid: false };
  }

  if (new Date(linkToken.expiresAt) < new Date()) {
    return { valid: false };
  }

  return { valid: true, tokenData: linkToken };
}

Link Account

export async function linkAccount(
  discordId: string,
  userId: string
): Promise<void> {
  const existing = await db
    .select()
    .from(discordLinks)
    .where(eq(discordLinks.discordId, discordId))
    .get();

  if (existing) {
    await db
      .update(discordLinks)
      .set({
        userId,
        verified: true,
        updatedAt: new Date(),
      })
      .where(eq(discordLinks.discordId, discordId));
  } else {
    await db.insert(discordLinks).values({
      discordId,
      userId,
      verified: true,
    });
  }
}

Email Verification

Send Link Email

import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendLinkEmail(
  email: string,
  token: string
): Promise<void> {
  const verificationUrl = `${process.env.WEB_URL}/verify-discord?token=${token}`;

  await resend.emails.send({
    from: 'Sigmagit <noreply@sigmagit.dev>',
    to: email,
    subject: 'Link your Discord account',
    html: `
      <h1>Link your Discord account</h1>
      <p>Click the link below to link your Discord account to Sigmagit:</p>
      <a href="${verificationUrl}">Link Account</a>
      <p>This link expires in 24 hours.</p>
    `,
  });
}

Complete Linking Flow

export async function verifyAndLinkAccount(
  token: string,
  sigmagitUserId: string
): Promise<{ success: boolean; user?: any; error?: string }> {
  const { valid, tokenData } = await validateLinkToken(token);

  if (!valid || !tokenData) {
    return { success: false, error: 'Invalid or expired token' };
  }

  // Mark token as used
  await db
    .update(linkTokens)
    .set({ used: true })
    .where(eq(linkTokens.token, token));

  // Link the account
  await linkAccount(tokenData.discordId, sigmagitUserId);

  // Get user data
  const user = await db
    .select()
    .from(users)
    .where(eq(users.id, sigmagitUserId))
    .get();

  return { success: true, user };
}

Discord Bot Integration

Link Command

// apps/discord-bot/src/commands/link.ts
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';

export async function handleLinkCommand(interaction: any, api: SigmagitApiClient) {
  await interaction.deferReply({ ephemeral: true });

  const discordId = interaction.user.id;

  const button = new ActionRowBuilder().addComponents(
    new ButtonBuilder()
      .setCustomId('link_start')
      .setLabel('πŸ”— Link Account')
      .setStyle(ButtonStyle.Primary)
  );

  const embed = new EmbedBuilder()
    .setTitle('πŸ”— Account Linking')
    .setDescription('Connect your Sigmagit account to enable notifications and personalized features.')
    .addFields(
      { name: 'What this enables:', value: 'β€’ Discord notifications for issues, PRs, and more\nβ€’ Personalized commands and mentions\nβ€’ Repository activity tracking' },
      { name: 'Privacy', value: 'β€’ Your Discord ID is linked securely\nβ€’ You can unlink at any time\nβ€’ Email is never shared' },
    )
    .setColor('Blue')
    .setTimestamp();

  await interaction.editReply({ embeds: [embed], components: [button] });
}

Link Email Command

export async function handleLinkEmailCommand(interaction: any, api: SigmagitApiClient) {
  await interaction.deferReply({ ephemeral: true });

  const email = interaction.options.getString('email');

  if (!email || !email.includes('@')) {
    await interaction.editReply({ content: '❌ Please provide a valid email address' });
    return;
  }

  const discordId = interaction.user.id;

  const response = await fetch(`${api['baseUrl']}/api/discord/link/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ discordId, sigmagitEmail: email }),
  });

  const data = await response.json();

  if (!response.ok || data.error) {
    await interaction.editReply({ content: `❌ ${data.error || 'Failed to generate link token'}` });
    return;
  }

  const embed = new EmbedBuilder()
    .setTitle('βœ… Link Token Generated')
    .setDescription(`A verification link has been generated for **${email}**`)
    .addFields(
      { name: 'Status', value: 'πŸ”— Pending Verification', inline: true },
      { name: 'Token', value: `πŸ“‹ ${data.token.substring(0, 16)}...`, inline: true },
    )
    .setColor('Green')
    .setTimestamp();

  await interaction.editReply({ embeds: [embed] });
}

Unlink Command

export async function handleUnlinkCommand(interaction: any, api: SigmagitApiClient) {
  await interaction.deferReply({ ephemeral: true });

  const discordId = interaction.user.id;

  const response = await fetch(`${api['baseUrl']}/api/discord/link/unlink`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ discordId }),
  });

  const data = await response.json();

  if (!response.ok || data.error) {
    await interaction.editReply({ content: `❌ ${data.error || 'Failed to unlink account'}` });
    return;
  }

  const embed = new EmbedBuilder()
    .setTitle('πŸ”“ Account Unlinked')
    .setDescription('Your Discord account has been unlinked from Sigmagit.')
    .setColor('Orange')
    .setTimestamp();

  await interaction.editReply({ embeds: [embed] });
}

Link Status Command

export async function handleStatusCommand(interaction: any, api: SigmagitApiClient) {
  await interaction.deferReply();

  const discordId = interaction.user.id;

  const response = await fetch(`${api['baseUrl']}/api/discord/link/status/${discordId}`);

  const data = await response.json();

  if (!response.ok) {
    await interaction.editReply({ content: '❌ Failed to check link status' });
    return;
  }

  if (!data.linked) {
    const embed = new EmbedBuilder()
      .setTitle('πŸ”— Not Linked')
      .setDescription('Your Discord account is not linked to Sigmagit.')
      .addFields(
        { name: 'Action', value: 'Use /link to connect your account', inline: false },
      )
      .setColor('Red')
      .setTimestamp();

    await interaction.editReply({ embeds: [embed] });
  } else {
    const embed = new EmbedBuilder()
      .setTitle('βœ… Account Link Status')
      .setDescription('Your Discord account is linked to Sigmagit.')
      .addFields(
        { name: 'Sigmagit User', value: data.user?.username || 'Unknown', inline: true },
        { name: 'Verified', value: data.verified ? 'βœ… Yes' : '⏳ Pending', inline: true },
        { name: 'Linked Since', value: new Date(data.linkedAt).toLocaleDateString(), inline: true },
      )
      .setColor(data.verified ? 'Green' : 'Yellow')
      .setTimestamp();

    await interaction.editReply({ embeds: [embed] });
  }
}

Webhook Notifications

After linking, users can receive Discord notifications:

export async function sendDiscordNotification(
  userId: string,
  event: WebhookEvent
): Promise<void> {
  const link = await db
    .select()
    .from(discordLinks)
    .where(eq(discordLinks.userId, userId))
    .get();

  if (!link || !link.verified) {
    return;
  }

  const webhook = await db
    .select()
    .from(discordWebhooks)
    .where(eq(discordWebhooks.userId, userId))
    .get();

  if (!webhook) {
    return;
  }

  const embed = createNotificationEmbed(event);

  await fetch(webhook.url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ embeds: [embed] }),
  });
}

Security Considerations

Token Security

  • Use cryptographically secure random tokens (32 bytes)
  • Set token expiry (24 hours recommended)
  • Mark tokens as used after verification
  • One-time use only

Email Security

  • Verify email ownership before linking
  • Use secure email sending (Resend or SMTP)
  • Don't expose full tokens in logs
  • Rate limit token generation

Privacy

  • Store minimal Discord data (only ID)
  • Allow users to unlink at any time
  • Don't share email with Discord
  • Clear data on unlink

Troubleshooting

Link Token Not Working

  • Check token hasn't expired (24 hours)
  • Verify token hasn't been used already
  • Check Discord ID matches
  • Verify email is correct

Email Not Received

  • Check spam folder
  • Verify SMTP settings
  • Confirm email address is correct
  • Check Resend API key

Account Already Linked

  • User must unlink first
  • Check existing link in database
  • Contact support if needed