Skip to content
Merged
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
21 changes: 1 addition & 20 deletions app/(auth)/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,31 +41,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
},
);

const { data, error } = await supabase.auth.exchangeCodeForSession(code);
const { error } = await supabase.auth.exchangeCodeForSession(code);

if (error) {
return NextResponse.redirect(new URL('/login?error=auth', baseUrl));
}

// After Twitter login, redirect to the platform connection flow if not yet connected.
// This ensures sign-in with Twitter also grants posting permissions (tweet.write).
if (data.user?.app_metadata?.provider === 'x' && next === '/app') {
const { data: existing } = await supabase
.from('platform_connections')
.select('id')
.eq('user_id', data.user.id)
.eq('platform', 'twitter')
.maybeSingle();

if (!existing) {
const response = NextResponse.redirect(new URL('/api/connect/twitter/authorize', baseUrl));
for (const { name, value, options } of pendingCookies) {
response.cookies.set(name, value, options);
}
return response;
}
}

const response = NextResponse.redirect(new URL(next, baseUrl));

for (const { name, value, options } of pendingCookies) {
Expand Down
28 changes: 12 additions & 16 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function LoginForm() {
router.push('/app');
}

async function handleOAuth(provider: 'google' | 'x' | 'linkedin_oidc') {
async function handleOAuth(provider: 'google') {
await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo: `${window.location.origin}/callback` },
Expand Down Expand Up @@ -180,22 +180,18 @@ function LoginForm() {
<GoogleIcon />
Google
</Button>
<Button
variant="outline"
onClick={() => handleOAuth('x')}
className="w-full"
>
<TwitterIcon />
Twitter
<Button variant="outline" asChild className="w-full">
<a href="/api/connect/twitter/authorize?intent=auth">
<TwitterIcon />
Twitter
</a>
</Button>
<Button variant="outline" asChild className="w-full">
<a href="/api/connect/linkedin/authorize?intent=auth">
<LinkedInIcon />
LinkedIn
</a>
</Button>
{/*<Button*/}
{/* variant="outline"*/}
{/* onClick={() => handleOAuth('linkedin_oidc')}*/}
{/* className="w-full"*/}
{/*>*/}
{/* <LinkedInIcon />*/}
{/* LinkedIn*/}
{/*</Button>*/}
</div>

<p className="text-center text-sm text-muted-foreground">
Expand Down
28 changes: 12 additions & 16 deletions app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default function RegisterPage() {
setIsLoading(false);
}

async function handleOAuth(provider: 'google' | 'x' | 'linkedin_oidc') {
async function handleOAuth(provider: 'google') {
await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo: `${window.location.origin}/callback` },
Expand Down Expand Up @@ -191,22 +191,18 @@ export default function RegisterPage() {
<GoogleIcon />
Google
</Button>
<Button
variant="outline"
onClick={() => handleOAuth('x')}
className="w-full"
>
<TwitterIcon />
Twitter
<Button variant="outline" asChild className="w-full">
<a href="/api/connect/twitter/authorize?intent=auth">
<TwitterIcon />
Twitter
</a>
</Button>
<Button variant="outline" asChild className="w-full">
<a href="/api/connect/linkedin/authorize?intent=auth">
<LinkedInIcon />
LinkedIn
</a>
</Button>
{/*<Button*/}
{/* variant="outline"*/}
{/* onClick={() => handleOAuth('linkedin_oidc')}*/}
{/* className="w-full"*/}
{/*>*/}
{/* <LinkedInIcon />*/}
{/* LinkedIn*/}
{/*</Button>*/}
</div>

<p className="text-center text-sm text-muted-foreground">
Expand Down
14 changes: 12 additions & 2 deletions app/api/connect/linkedin/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { randomBytes } from 'crypto';

import { NextResponse } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';

import { getAppUrl } from '@/lib/twitter';

const LINKEDIN_AUTHORIZE_URL = 'https://www.linkedin.com/oauth/v2/authorization';
const COOKIE_MAX_AGE = 600; // 10 minutes

export async function GET(): Promise<NextResponse> {
export async function GET(request: NextRequest): Promise<NextResponse> {
const intent = request.nextUrl.searchParams.get('intent');
const state = randomBytes(16).toString('hex');

const params = new URLSearchParams({
Expand All @@ -28,5 +29,14 @@ export async function GET(): Promise<NextResponse> {
path: '/',
});

if (intent === 'auth') {
response.cookies.set('linkedin_auth_intent', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: COOKIE_MAX_AGE,
path: '/',
});
}

return response;
}
110 changes: 99 additions & 11 deletions app/api/connect/linkedin/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';

import { createServerClient } from '@/lib/supabase/server';
import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server';
import { getAppUrl } from '@/lib/twitter';

const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken';
const LINKEDIN_USERINFO_URL = 'https://api.linkedin.com/v2/userinfo';

function redirectWithError(_request: NextRequest, error: string): NextResponse {
return NextResponse.redirect(
new URL(`/app/settings/accounts?error=${error}`, getAppUrl()),
);
function redirectWithError(_request: NextRequest, error: string, isAuthIntent = false): NextResponse {
const path = isAuthIntent
? `/login?error=linkedin_auth`
: `/app/settings/accounts?error=${error}`;
return NextResponse.redirect(new URL(path, getAppUrl()));
}

export async function GET(request: NextRequest): Promise<NextResponse> {
const code = request.nextUrl.searchParams.get('code');
const state = request.nextUrl.searchParams.get('state');
const storedState = request.cookies.get('linkedin_state')?.value;
const authIntent = request.cookies.get('linkedin_auth_intent')?.value === 'true';

if (!code || !storedState) {
return redirectWithError(request, 'missing_params');
return redirectWithError(request, 'missing_params', authIntent);
}

if (state !== storedState) {
return redirectWithError(request, 'state_mismatch');
return redirectWithError(request, 'state_mismatch', authIntent);
}

// Exchange code for tokens
Expand All @@ -39,13 +41,13 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
});

if (!tokenResponse.ok) {
return redirectWithError(request, 'token_exchange');
return redirectWithError(request, 'token_exchange', authIntent);
}

const tokens = await tokenResponse.json();

if (!tokens.access_token) {
return redirectWithError(request, 'token_exchange');
return redirectWithError(request, 'token_exchange', authIntent);
}

// Fetch user profile via OpenID Connect userinfo
Expand All @@ -54,12 +56,98 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
});

if (!userResponse.ok) {
return redirectWithError(request, 'profile_fetch');
return redirectWithError(request, 'profile_fetch', authIntent);
}

const linkedInUser = await userResponse.json();

// Store in database
if (authIntent) {
// Branch B: find/create Supabase user, upsert connection, generate magic link
const serviceClient = createServiceRoleClient();

// Check for existing platform connection
const { data: existingConnection } = await serviceClient
.from('platform_connections')
.select('user_id')
.eq('platform', 'linkedin')
.eq('platform_user_id', linkedInUser.sub)
.maybeSingle();

let userId: string;
let userEmail: string;

if (existingConnection) {
userId = existingConnection.user_id;
const { data: adminUserData } = await serviceClient.auth.admin.getUserById(userId);
userEmail = adminUserData.user?.email ?? `linkedin_${linkedInUser.sub}@linkedin.socio.app`;
} else {
// Use real LinkedIn email if available, otherwise synthetic
userEmail = linkedInUser.email ?? `linkedin_${linkedInUser.sub}@linkedin.socio.app`;
const { data: newUserData, error: createError } = await serviceClient.auth.admin.createUser({
email: userEmail,
email_confirm: true,
user_metadata: {
full_name: linkedInUser.name,
avatar_url: linkedInUser.picture,
},
});

if (createError || !newUserData.user) {
return redirectWithError(request, 'user_create', true);
}

userId = newUserData.user.id;
}

// Upsert platform connection
const { error: upsertError } = await serviceClient
.from('platform_connections')
.upsert(
{
user_id: userId,
platform: 'linkedin',
access_token: tokens.access_token,
refresh_token: tokens.refresh_token ?? null,
token_expires_at: tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
: null,
platform_user_id: linkedInUser.sub ?? null,
platform_username: linkedInUser.email ?? null,
platform_display_name: linkedInUser.name ?? null,
platform_avatar_url: linkedInUser.picture ?? null,
scopes: tokens.scope ?? null,
updated_at: new Date().toISOString(),
},
{ onConflict: 'user_id,platform' },
);

if (upsertError) {
return redirectWithError(request, 'db_error', true);
}

// Generate magic link to establish session
const { data: linkData, error: linkError } = await serviceClient.auth.admin.generateLink({
type: 'magiclink',
email: userEmail,
options: { redirectTo: getAppUrl() + '/app' },
});

if (linkError || !linkData.properties?.hashed_token) {
return redirectWithError(request, 'magic_link', true);
}

const confirmUrl = new URL('/auth/confirm', getAppUrl());
confirmUrl.searchParams.set('token_hash', linkData.properties.hashed_token);
confirmUrl.searchParams.set('next', '/app');

const response = NextResponse.redirect(confirmUrl);
response.cookies.delete('linkedin_state');
response.cookies.delete('linkedin_auth_intent');

return response;
}

// Branch A: require logged-in user, upsert connection, redirect to settings
const supabase = await createServerClient();
const { data: { user } } = await supabase.auth.getUser();

Expand Down
14 changes: 12 additions & 2 deletions app/api/connect/twitter/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { createHash, randomBytes } from 'crypto';

import { NextResponse } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';

import { getTwitterCallbackUrl, getAppUrl } from '@/lib/twitter';

const TWITTER_AUTHORIZE_URL = 'https://x.com/i/oauth2/authorize';
const COOKIE_MAX_AGE = 600; // 10 minutes

export async function GET(): Promise<NextResponse> {
export async function GET(request: NextRequest): Promise<NextResponse> {
const intent = request.nextUrl.searchParams.get('intent');
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
.update(codeVerifier)
Expand Down Expand Up @@ -42,5 +43,14 @@ export async function GET(): Promise<NextResponse> {
path: '/',
});

if (intent === 'auth') {
response.cookies.set('twitter_auth_intent', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: COOKIE_MAX_AGE,
path: '/',
});
}

return response;
}
Loading
Loading