Реалізація Account Linking (прив'язка соцмереж до акаунту) на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Account Linking (прив'язка соцмереж до акаунту) на сайті
Середня
від 1 робочого дня до 3 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Привязка аккаунтів соціальних мереж

Привязка дозволяє користувачу з email/password аккаунтом додати Google або GitHub як спосіб входу — і навпаки. Головна складність: безпечно ідентифікувати власника перед привязкою.

Архітектура: схема даних

model User {
  id           String        @id @default(cuid())
  email        String        @unique
  passwordHash String?       // null якщо тільки соцмережі
  accounts     LinkedAccount[]
}

model LinkedAccount {
  id                String   @id @default(cuid())
  userId            String
  provider          String   // 'google' | 'github' | 'apple'
  providerAccountId String   // ID у системі провайдера
  providerEmail     String?  // email від провайдера
  linkedAt          DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@index([userId])
}

Server Actions: привязка провайдера

// app/settings/security/actions.ts
'use server';

import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { generateState } from '@/lib/oauth';

export async function initiateLinkProvider(provider: string) {
  const session = await auth();
  if (!session) redirect('/login');

  // Генеруємо state з userId (захист від CSRF)
  const state = await generateState({
    userId: session.user.id,
    action: 'link',
    provider,
  });

  // Перенаправляємо на OAuth flow з параметром link=true
  redirect(`/api/auth/link/${provider}?state=${state}`);
}

export async function unlinkProvider(accountId: string) {
  const session = await auth();
  if (!session) redirect('/login');

  // Перевіряємо, що це аккаунт поточного користувача
  const account = await db.linkedAccount.findFirst({
    where: { id: accountId, userId: session.user.id }
  });

  if (!account) {
    throw new Error('Account not found');
  }

  // Не можна відвязати єдиний спосіб входу
  const accountsCount = await db.linkedAccount.count({
    where: { userId: session.user.id }
  });
  const hasPassword = await db.user.findUnique({
    where: { id: session.user.id },
    select: { passwordHash: true }
  });

  if (accountsCount <= 1 && !hasPassword?.passwordHash) {
    throw new Error('Cannot unlink the only login method');
  }

  await db.linkedAccount.delete({ where: { id: accountId } });
}

OAuth Callback: обробка привязки

// app/api/auth/callback/[provider]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { provider: string } }
) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');
  const state = searchParams.get('state');

  // Розшифровуємо state
  const stateData = await verifyState(state);
  if (!stateData || stateData.action !== 'link') {
    redirect('/settings/security?error=invalid_state');
  }

  // Обмінюємо code на access token
  const tokens = await exchangeCode(params.provider, code);
  const providerUser = await fetchProviderUser(params.provider, tokens.accessToken);

  // Перевіряємо: не привязаний ли вже цей аккаунт до іншого користувача
  const existingLink = await db.linkedAccount.findUnique({
    where: {
      provider_providerAccountId: {
        provider: params.provider,
        providerAccountId: providerUser.id,
      }
    }
  });

  if (existingLink && existingLink.userId !== stateData.userId) {
    redirect('/settings/security?error=account_already_linked');
  }

  if (!existingLink) {
    await db.linkedAccount.create({
      data: {
        userId: stateData.userId,
        provider: params.provider,
        providerAccountId: providerUser.id,
        providerEmail: providerUser.email,
      }
    });
  }

  redirect('/settings/security?success=linked');
}

UI компонент управління

// components/LinkedAccountsManager.tsx
'use client';

import { useState, useTransition } from 'react';
import { initiateLinkProvider, unlinkProvider } from './actions';

const PROVIDERS = [
  { id: 'google', name: 'Google', icon: <GoogleIcon /> },
  { id: 'github', name: 'GitHub', icon: <GitHubIcon /> },
  { id: 'apple', name: 'Apple', icon: <AppleIcon /> },
];

export function LinkedAccountsManager({
  linkedAccounts,
  hasPassword,
}: {
  linkedAccounts: Array<{ id: string; provider: string; providerEmail: string | null }>;
  hasPassword: boolean;
}) {
  const [isPending, startTransition] = useTransition();
  const linkedProviders = new Set(linkedAccounts.map(a => a.provider));

  const canUnlink = (provider: string) => {
    // Не можна відвязати якщо це єдиний спосіб входу
    const otherAccounts = linkedAccounts.filter(a => a.provider !== provider);
    return hasPassword || otherAccounts.length > 0;
  };

  return (
    <div className="space-y-4">
      {PROVIDERS.map((provider) => {
        const linked = linkedAccounts.find(a => a.provider === provider.id);

        return (
          <div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
            <div className="flex items-center gap-3">
              {provider.icon}
              <div>
                <p className="font-medium">{provider.name}</p>
                {linked && (
                  <p className="text-sm text-gray-500">{linked.providerEmail}</p>
                )}
              </div>
            </div>

            {linked ? (
              <button
                onClick={() => startTransition(() => unlinkProvider(linked.id))}
                disabled={!canUnlink(provider.id) || isPending}
                className="text-red-600 disabled:opacity-50"
              >
                Відвязати
              </button>
            ) : (
              <button
                onClick={() => startTransition(() => initiateLinkProvider(provider.id))}
                disabled={isPending}
                className="text-blue-600"
              >
                Привязати
              </button>
            )}
          </div>
        );
      })}
    </div>
  );
}

Реалізація привязки/відвязання OAuth провайдерів з захистом від блокування аккаунта — 2–3 робочі дні.