From 8451ecce92e9054b8c9134b2e1cdf26e928ed6e4 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 17 Dec 2023 11:21:51 +0300 Subject: [PATCH 1/2] feat: add login with github --- app/(auth)/auth/callback/route.ts | 19 ++--- .../auth/sign-in/{ => [:provider]}/route.ts | 33 ++++++-- app/(auth)/sign-in/page.tsx | 69 +++++++++++++---- .../episode-ai-summary-footer.tsx | 4 +- components/layout/app-header.tsx | 14 ++-- components/layout/app-layout.tsx | 2 +- lib/services/account.ts | 54 ++++++++++++- lib/services/show.ts | 2 +- lib/services/spotify/save-user-info.ts | 76 ------------------- lib/services/supabase/auth.ts | 4 + middleware.ts | 10 ++- types/supabase/database.ts | 19 ++--- 12 files changed, 170 insertions(+), 136 deletions(-) rename app/(auth)/auth/sign-in/{ => [:provider]}/route.ts (57%) delete mode 100644 lib/services/spotify/save-user-info.ts diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index 0a3d844..9a4798e 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -5,7 +5,7 @@ import { HttpBadRequestError, HttpInternalServerError, } from '@/lib/errors'; -import { saveUserInfo } from '@/lib/services/spotify/save-user-info'; +import { updateAccount } from '@/lib/services/account'; import { createSupabaseServerClient } from '@/lib/services/supabase/server'; import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; @@ -14,7 +14,6 @@ import { ZodError } from 'zod'; export async function GET(request: NextRequest) { const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get('code'); - const redirect = requestUrl.searchParams.get('redirect'); if (!code) { return new HttpBadRequestError({ @@ -35,18 +34,10 @@ export async function GET(request: NextRequest) { } try { - const { isNewAccount } = await saveUserInfo({ session, user }); - - if (isNewAccount) { - return NextResponse.redirect( - new URL('/onboarding/start', request.url).toString(), - { - status: 301, - }, - ); - } - - return NextResponse.redirect(new URL(redirect || '/shows', request.url)); + await updateAccount({ session, user }); + const redirect = requestUrl.searchParams.get('redirect'); + const redirectURL = new URL(redirect || '/shows', request.url); + return NextResponse.redirect(redirectURL); } catch (error) { switch (true) { case error instanceof DatabaseError: diff --git a/app/(auth)/auth/sign-in/route.ts b/app/(auth)/auth/sign-in/[:provider]/route.ts similarity index 57% rename from app/(auth)/auth/sign-in/route.ts rename to app/(auth)/auth/sign-in/[:provider]/route.ts index 6c8ccf6..5c9c961 100644 --- a/app/(auth)/auth/sign-in/route.ts +++ b/app/(auth)/auth/sign-in/[:provider]/route.ts @@ -1,3 +1,4 @@ +import type { SignInWithOAuthCredentials } from '@supabase/supabase-js'; import type { NextRequest } from 'next/server'; import { HttpAuthenticationError } from '@/lib/errors'; @@ -5,23 +6,43 @@ import { createSupabaseServerClient } from '@/lib/services/supabase/server'; import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; -export async function POST(request: NextRequest) { - const supabase = createSupabaseServerClient(cookies()); +const getOAuthConfig = ( + provider: string, +): { + provider: SignInWithOAuthCredentials['provider']; + scopes?: string; +} => { + switch (provider) { + case 'spotify': + return { + provider, + scopes: 'user-read-email user-read-playback-position user-library-read', + }; + + default: + return { + provider: 'github', + }; + } +}; +export async function POST( + request: NextRequest, + { params }: { params: { provider: string } }, +) { const formData = await request.formData(); const redirect = String(formData.get('redirect')); - const redirectURL = new URL('/auth/callback', request.url); redirectURL.searchParams.set('redirect', redirect); - const scopes = - 'user-read-email user-read-playback-position user-library-read'; + const supabase = createSupabaseServerClient(cookies()); + const { provider, scopes } = getOAuthConfig(params.provider); const result = await supabase.auth.signInWithOAuth({ options: { redirectTo: redirectURL.toString(), scopes, }, - provider: 'spotify', + provider, }); if (result.error) { diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index 2cfe8fc..d2f671f 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,5 +1,10 @@ +import type { SignInWithOAuthCredentials } from '@supabase/supabase-js'; + +import { ENABLED_OAUTH_PROVIDERS } from '@/lib/services/supabase/auth'; import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes'; +import { CgLogIn } from 'react-icons/cg'; import { FaSpotify } from 'react-icons/fa'; +import { FaGithub } from 'react-icons/fa6'; type Props = { searchParams?: { @@ -7,6 +12,36 @@ type Props = { }; }; +function AuthProviderLabel(props: { + provider: SignInWithOAuthCredentials['provider']; +}) { + switch (props.provider) { + case 'spotify': + return 'Spotify'; + + case 'github': + return 'GitHub'; + + default: + return props.provider; + } +} + +function AuthProviderIcon(props: { + provider: SignInWithOAuthCredentials['provider']; +}) { + switch (props.provider) { + case 'spotify': + return ; + + case 'github': + return ; + + default: + return ; + } +} + export default function Page(props: Props) { return ( @@ -16,20 +51,26 @@ export default function Page(props: Props) { A more efficient way to listen podcasts. -
- - - - - -
+ + {ENABLED_OAUTH_PROVIDERS.map((provider) => ( +
+
+ + + + + +
+
+ ))} +
By clicking continue, you acknowledge that you have read and understood, diff --git a/components/episode-ai-summary/episode-ai-summary-footer.tsx b/components/episode-ai-summary/episode-ai-summary-footer.tsx index 7520b15..45f7526 100644 --- a/components/episode-ai-summary/episode-ai-summary-footer.tsx +++ b/components/episode-ai-summary/episode-ai-summary-footer.tsx @@ -20,7 +20,7 @@ const fetchData = async (id: Props['id']) => { const { data } = await supabase .from('episode_content') - .select('id, user:account(id, display_name, avatar_url)') + .select('id, user:account(id, name, avatar_url)') .eq('episode', id) .single(); @@ -59,7 +59,7 @@ export function EpisodeAISummaryFooter(props: Props) { /> - Generated by {data.user.display_name} + Generated by {data.user.name}
) : ( diff --git a/components/layout/app-header.tsx b/components/layout/app-header.tsx index 56b7397..916567d 100644 --- a/components/layout/app-header.tsx +++ b/components/layout/app-header.tsx @@ -29,7 +29,7 @@ type Props = user: { avatarURL?: string; credits: number; - username: string; + name: string; }; variant: 'authenticated'; } @@ -38,16 +38,16 @@ type Props = }; const USER_MENU_LINKS = [ - { - href: '/credits', - icon: , - label: 'Buy credits', - }, { href: '/shows', icon: , label: 'Your shows', }, + { + href: '/credits', + icon: , + label: 'Buy credits', + }, ]; function AppHeaderActionsAuthenticated( @@ -77,7 +77,7 @@ function AppHeaderActionsAuthenticated( - {props.user.username} + {props.user.name} diff --git a/components/layout/app-layout.tsx b/components/layout/app-layout.tsx index 7879a06..be354b6 100644 --- a/components/layout/app-layout.tsx +++ b/components/layout/app-layout.tsx @@ -19,7 +19,7 @@ export async function AppLayout({ children }: PropsWithChildren) { user={{ avatarURL: account.avatar_url ?? '', credits: account.ai_credit, - username: account.display_name ?? '', + name: account.name, }} variant="authenticated" /> diff --git a/lib/services/account.ts b/lib/services/account.ts index 61ee0a3..6527703 100644 --- a/lib/services/account.ts +++ b/lib/services/account.ts @@ -1,12 +1,64 @@ 'use server'; +import type { Session, User } from '@supabase/supabase-js'; + +import { DatabaseError } from '@/lib/errors'; +import { differenceInMinutes } from 'date-fns'; import { cookies } from 'next/headers'; import { z } from 'zod'; -import { DatabaseError } from '../errors'; +import { notifySlack } from './notify/slack'; import { getUser } from './supabase/auth'; import { createSupabaseServerClient } from './supabase/server'; +type UserMetadata = { + avatar_url?: string; + name?: string; + preferred_username?: string; + provider_id?: string; + user_name?: string; +}; + +export const updateAccount = async ({ + session, + user, +}: { + session: Session; + user: User; +}) => { + const metadata = user.user_metadata as UserMetadata; + const supabase = createSupabaseServerClient(cookies()); + + const { data, error } = await supabase + .from('account') + .upsert( + { + avatar_url: metadata.avatar_url || '', + name: + metadata.preferred_username || metadata.user_name || user.email || '', + provider_refresh_token: session.provider_refresh_token || '', + provider_token: session.provider_token || '', + user_id: user.id, + }, + { onConflict: 'user_id' }, + ) + .select('name, created_at') + .single(); + + if (error) { + throw new DatabaseError(error); + } + + const isNewAccount = + differenceInMinutes(new Date(), new Date(data.created_at)) <= 5; + + if (isNewAccount) { + await notifySlack(`🐝 New sign-up for *beecast*: ${data.name}`); + } + + return { isNewAccount }; +}; + export const getAccountId = async () => { const supabase = createSupabaseServerClient(cookies()); diff --git a/lib/services/show.ts b/lib/services/show.ts index 1e6011b..563896c 100644 --- a/lib/services/show.ts +++ b/lib/services/show.ts @@ -101,7 +101,7 @@ const saveShowToImported = async ( const { data, error } = await supabase.from('imported_show').insert({ podcast_index_guid: podcastIndexGuid, show: showId, - spotify_id: spotifyId, + spotify_id: spotifyId || '', }); if (error) { diff --git a/lib/services/spotify/save-user-info.ts b/lib/services/spotify/save-user-info.ts deleted file mode 100644 index c2000d5..0000000 --- a/lib/services/spotify/save-user-info.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Session, User } from '@supabase/supabase-js'; - -import { DatabaseError } from '@/lib/errors'; -import { differenceInMinutes } from 'date-fns'; -import { cookies } from 'next/headers'; -import { z } from 'zod'; - -import { notifySlack } from '../notify/slack'; -import { createSupabaseServerClient } from '../supabase/server'; - -type UserMetadata = { - avatar_url?: string; - full_name?: string; - provider_id?: string; -}; - -const spotifyUserInfoResponseSchema = z.object({ - avatar_url: z.string().default(''), - full_name: z.string().default(''), - provider_id: z.string().min(1), - provider_token: z.string().min(1), -}); - -export const saveUserInfo = async ({ - session, - user, -}: { - session: Session; - user: User; -}) => { - const providerToken = session.provider_token; - const userMetadata: UserMetadata = user.user_metadata; - - const validatedResponse = spotifyUserInfoResponseSchema.safeParse({ - avatar_url: userMetadata.avatar_url, - full_name: userMetadata.full_name, - provider_id: userMetadata.provider_id, - provider_token: providerToken, - }); - - if (!validatedResponse.success) { - throw validatedResponse.error; - } - - const userInfo = validatedResponse.data; - const supabase = createSupabaseServerClient(cookies()); - - const { data, error } = await supabase - .from('account') - .upsert( - { - avatar_url: userInfo.avatar_url, - display_name: userInfo.full_name, - provider_refresh_token: session.provider_refresh_token, - provider_token: userInfo.provider_token, - spotify_id: userInfo.provider_id, - user_id: user.id, - }, - { onConflict: 'spotify_id' }, - ) - .select('display_name, created_at') - .single(); - - if (error) { - throw new DatabaseError(error); - } - - const isNewAccount = - differenceInMinutes(new Date(), new Date(data.created_at)) <= 5; - - if (isNewAccount) { - await notifySlack(`🐝 New sign-up for *beecast*: ${data.display_name}`); - } - - return { isNewAccount }; -}; diff --git a/lib/services/supabase/auth.ts b/lib/services/supabase/auth.ts index 00e1f4d..ba33ee0 100644 --- a/lib/services/supabase/auth.ts +++ b/lib/services/supabase/auth.ts @@ -1,5 +1,6 @@ import type { Database } from '@/types/supabase/database'; import type { CookieOptions } from '@supabase/ssr'; +import type { SignInWithOAuthCredentials } from '@supabase/supabase-js'; import type { NextRequest } from 'next/server'; import { env } from '@/env.mjs'; @@ -86,3 +87,6 @@ export const getUser = async () => { return userQuery.data.user; }; + +export const ENABLED_OAUTH_PROVIDERS: SignInWithOAuthCredentials['provider'][] = + ['github', 'spotify']; diff --git a/middleware.ts b/middleware.ts index 16c4816..fcf31b3 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,15 +1,19 @@ import type { NextRequest } from 'next/server'; -import { getSupabaseAuthSession } from '@/lib/services/supabase/auth'; +import { + ENABLED_OAUTH_PROVIDERS, + getSupabaseAuthSession, +} from '@/lib/services/supabase/auth'; import { NextResponse } from 'next/server'; const publicRoutes = [ '/', '/auth/callback', - '/auth/sign-in', '/sign-in', '/api/webhooks/stripe', -]; +].concat( + ENABLED_OAUTH_PROVIDERS.map((provider) => `/auth/sign-in/${provider}`), +); export async function middleware(request: NextRequest) { const { pathname } = new URL(request.url); diff --git a/types/supabase/database.ts b/types/supabase/database.ts index ef31ca7..28c3b1a 100644 --- a/types/supabase/database.ts +++ b/types/supabase/database.ts @@ -14,47 +14,44 @@ export interface Database { ai_credit: number; avatar_url: string | null; created_at: string; - display_name: string | null; id: number; + name: string; provider_refresh_token: string | null; provider_token: string; - spotify_id: string; user_id: string; }; Insert: { ai_credit?: number; avatar_url?: string | null; created_at?: string; - display_name?: string | null; id?: number; + name: string; provider_refresh_token?: string | null; provider_token: string; - spotify_id: string; user_id: string; }; Update: { ai_credit?: number; avatar_url?: string | null; created_at?: string; - display_name?: string | null; id?: number; + name?: string; provider_refresh_token?: string | null; provider_token?: string; - spotify_id?: string; user_id?: string; }; Relationships: [ { foreignKeyName: 'account_user_fkey'; columns: ['user_id']; - isOneToOne: false; + isOneToOne: true; referencedRelation: 'users'; referencedColumns: ['id']; }, { foreignKeyName: 'account_user_id_fkey'; columns: ['user_id']; - isOneToOne: false; + isOneToOne: true; referencedRelation: 'users'; referencedColumns: ['id']; }, @@ -321,7 +318,7 @@ export interface Database { language: string | null; podcast_index_guid: string; publisher: string; - spotify_id: string; + spotify_id: string | null; title: string; total_episode: number | null; }; @@ -333,7 +330,7 @@ export interface Database { language?: string | null; podcast_index_guid: string; publisher: string; - spotify_id: string; + spotify_id?: string | null; title: string; total_episode?: number | null; }; @@ -345,7 +342,7 @@ export interface Database { language?: string | null; podcast_index_guid?: string; publisher?: string; - spotify_id?: string; + spotify_id?: string | null; title?: string; total_episode?: number | null; }; From a2407fdc0ef6eca3e2ec8e34b4fdfeeb2a2e5ce9 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 17 Dec 2023 11:25:31 +0300 Subject: [PATCH 2/2] fix: types --- components/layout/app-header.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/layout/app-header.stories.tsx b/components/layout/app-header.stories.tsx index 6122b60..5feab27 100644 --- a/components/layout/app-header.stories.tsx +++ b/components/layout/app-header.stories.tsx @@ -11,7 +11,7 @@ export const Authenticated: StoryObj = {