From 0361ecbd50fd3bae18c647be884d9ba0e2ec80a5 Mon Sep 17 00:00:00 2001 From: TheSawkit <23707419+TheSawkit@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:45:29 +0200 Subject: [PATCH 1/5] Fix : Resolve avatar update and onboarding google auth issues Closed #17 --- app/[lang]/(protected)/layout.tsx | 24 +++- .../(protected)/profile/[username]/page.tsx | 6 +- app/[lang]/(protected)/settings/actions.ts | 73 +++++++---- app/[lang]/(protected)/settings/page.tsx | 2 + app/[lang]/onboarding/actions.ts | 56 +++++++++ app/[lang]/onboarding/loading.tsx | 24 ++++ app/[lang]/onboarding/page.tsx | 53 ++++++++ app/actions/friends.ts | 19 +-- app/actions/playlists.ts | 11 +- app/api/search/route.ts | 11 +- components/navigation/Navbar.tsx | 20 ++- components/navigation/NavbarClient.tsx | 3 + components/onboarding/OnboardingForm.tsx | 118 ++++++++++++++++++ components/settings/DangerZone.tsx | 37 ++++-- components/settings/PasswordSettings.tsx | 20 ++- components/settings/ProfileSettings.tsx | 15 ++- components/settings/SettingsContent.tsx | 13 +- lib/avatar.ts | 13 ++ lib/i18n/translations.ts | 40 ++++++ lib/onboarding.ts | 75 +++++++++++ lib/supabase/auth-helpers.ts | 10 ++ lib/supabase/columns.ts | 2 +- tests/unit/avatar.test.ts | 24 ++++ tests/unit/onboarding.test.ts | 104 +++++++++++++++ types/database.ts | 6 + types/profile.ts | 2 + 26 files changed, 701 insertions(+), 80 deletions(-) create mode 100644 app/[lang]/onboarding/actions.ts create mode 100644 app/[lang]/onboarding/loading.tsx create mode 100644 app/[lang]/onboarding/page.tsx create mode 100644 components/onboarding/OnboardingForm.tsx create mode 100644 lib/avatar.ts create mode 100644 lib/onboarding.ts create mode 100644 tests/unit/avatar.test.ts create mode 100644 tests/unit/onboarding.test.ts diff --git a/app/[lang]/(protected)/layout.tsx b/app/[lang]/(protected)/layout.tsx index fbf0c54..fb91a5f 100644 --- a/app/[lang]/(protected)/layout.tsx +++ b/app/[lang]/(protected)/layout.tsx @@ -1,11 +1,31 @@ +import { redirect } from 'next/navigation'; import { requireAuth } from '@/lib/auth'; +import { createClient } from '@/lib/supabase/server'; +import { getServerLanguage } from '@/lib/i18n/server'; +import { localizedHref } from '@/lib/i18n/utils'; +import { needsOnboarding } from '@/lib/onboarding'; -/** Auth boundary for every route in the (protected) group — redirects to /login when unauthenticated. */ +/** + * Auth boundary for every route in the (protected) group — redirects to /login when + * unauthenticated, or to /onboarding while the profile (username + region) is incomplete. + */ export default async function ProtectedLayout({ children, }: { children: React.ReactNode; }) { - await requireAuth(); + const user = await requireAuth(); + const supabase = await createClient(); + + const { data: profile } = await supabase + .from('user_profiles') + .select('onboarding_completed') + .eq('user_id', user.id) + .maybeSingle(); + + if (needsOnboarding(user.user_metadata, profile?.onboarding_completed)) { + redirect(localizedHref(await getServerLanguage(), '/onboarding')); + } + return <>{children}; } diff --git a/app/[lang]/(protected)/profile/[username]/page.tsx b/app/[lang]/(protected)/profile/[username]/page.tsx index 3bd3b6b..aac71ce 100644 --- a/app/[lang]/(protected)/profile/[username]/page.tsx +++ b/app/[lang]/(protected)/profile/[username]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'; import { Suspense } from 'react'; import { requireAuth } from '@/lib/auth'; import { createClient, createAdminClient } from '@/lib/supabase/server'; +import { resolveAvatarUrl } from '@/lib/avatar'; import { getTranslations } from '@/lib/i18n/server'; import { PageLayout } from '@/components/layout/PageLayout'; import { ProfileHero } from '@/components/profile/ProfileHero'; @@ -169,9 +170,8 @@ export default async function ProfilePage({ params }: Props) { const ownerMeta = ownerAuth.data.user?.user_metadata; const avatarUrl = - typeof ownerMeta?.avatar_url === 'string' - ? ownerMeta.avatar_url - : undefined; + resolveAvatarUrl(profile.avatar_url, ownerMeta?.avatar_url) ?? + undefined; const fullName = typeof ownerMeta?.full_name === 'string' ? ownerMeta.full_name diff --git a/app/[lang]/(protected)/settings/actions.ts b/app/[lang]/(protected)/settings/actions.ts index c4b0235..fd4846d 100644 --- a/app/[lang]/(protected)/settings/actions.ts +++ b/app/[lang]/(protected)/settings/actions.ts @@ -3,6 +3,7 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { createClient, createAdminClient } from '@/lib/supabase/server'; +import { isOAuthOnly } from '@/lib/supabase/auth-helpers'; import { getTranslations, getServerLanguage } from '@/lib/i18n/server'; import { localizedHref } from '@/lib/i18n/utils'; import { @@ -44,6 +45,10 @@ export async function updateEmail(prevState: unknown, formData: FormData) { return { error: t.auth.notAuthenticated, success: false }; } + if (isOAuthOnly(user)) { + return { error: t.settings.profile.warningEmail, success: false }; + } + const newEmail = validateEmail(formData.get('email')); if (!newEmail) { @@ -183,18 +188,24 @@ export async function updateAvatar(prevState: unknown, formData: FormData) { const fileName = `${user.id}-${Date.now()}.${fileExt}`; const buffer = await avatarFile.arrayBuffer(); - - const oldAvatarUrl = user.user_metadata?.avatar_url as - | string - | undefined; + const adminClient = createAdminClient(); + + const { data: currentProfile } = await supabase + .from('user_profiles') + .select('avatar_url') + .eq('user_id', user.id) + .maybeSingle(); + const oldAvatarUrl = + currentProfile?.avatar_url ?? + (user.user_metadata?.avatar_url as string | undefined); if (oldAvatarUrl?.includes('/avatars/')) { const oldPath = oldAvatarUrl.split('/avatars/')[1]?.split('?')[0]; if (oldPath) { - await supabase.storage.from('avatars').remove([oldPath]); + await adminClient.storage.from('avatars').remove([oldPath]); } } - const { error: uploadError } = await supabase.storage + const { error: uploadError } = await adminClient.storage .from('avatars') .upload(fileName, buffer, { contentType: avatarFile.type, @@ -205,18 +216,26 @@ export async function updateAvatar(prevState: unknown, formData: FormData) { return { error: uploadError.message, success: false }; } - const { data: publicUrlData } = supabase.storage + const { data: publicUrlData } = adminClient.storage .from('avatars') .getPublicUrl(fileName); finalAvatarUrl = `${publicUrlData.publicUrl}?t=${Date.now()}`; } - const { error } = await supabase.auth.updateUser({ - data: { + const username = user.user_metadata?.username as string | undefined; + if (!username) { + return { error: t.settings.missingFields, success: false }; + } + + const { error } = await supabase.from('user_profiles').upsert( + { + user_id: user.id, + username, avatar_url: finalAvatarUrl, - picture: finalAvatarUrl, + updated_at: new Date().toISOString(), }, - }); + { onConflict: 'user_id' } + ); if (error) { return { error: error.message, success: false }; @@ -255,23 +274,25 @@ export async function deleteAccount(prevState: unknown, formData: FormData) { }; } - if (typeof password !== 'string' || !password) { - return { - error: t.settings.dangerZone.passwordRequired, - success: false, - }; - } + if (!isOAuthOnly(user)) { + if (typeof password !== 'string' || !password) { + return { + error: t.settings.dangerZone.passwordRequired, + success: false, + }; + } - const { error: signInError } = await supabase.auth.signInWithPassword({ - email: user.email!, - password, - }); + const { error: signInError } = await supabase.auth.signInWithPassword({ + email: user.email!, + password, + }); - if (signInError) { - return { - error: t.settings.dangerZone.incorrectPassword, - success: false, - }; + if (signInError) { + return { + error: t.settings.dangerZone.incorrectPassword, + success: false, + }; + } } await supabase.from('episode_watches').delete().eq('user_id', user.id); diff --git a/app/[lang]/(protected)/settings/page.tsx b/app/[lang]/(protected)/settings/page.tsx index 10bce3d..9bbd405 100644 --- a/app/[lang]/(protected)/settings/page.tsx +++ b/app/[lang]/(protected)/settings/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react'; import { requireAuth } from '@/lib/auth'; import { createClient } from '@/lib/supabase/server'; +import { isOAuthOnly } from '@/lib/supabase/auth-helpers'; import { SettingsContent } from '@/components/settings/SettingsContent'; import { SettingsContentSkeleton } from '@/components/settings/SettingsContentSkeleton'; import { PageLayout, PageHeader } from '@/components/layout/PageLayout'; @@ -47,6 +48,7 @@ async function SettingsSection({ user }: { user: User }) { user={user} userProfile={userProfile} privacySettings={privacySettings} + isOAuthOnly={isOAuthOnly(user)} /> ); } diff --git a/app/[lang]/onboarding/actions.ts b/app/[lang]/onboarding/actions.ts new file mode 100644 index 0000000..bb5d889 --- /dev/null +++ b/app/[lang]/onboarding/actions.ts @@ -0,0 +1,56 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { getAuthenticatedUser } from '@/lib/supabase/auth-helpers'; +import { getServerLanguage, getTranslations } from '@/lib/i18n/server'; +import { localizedHref } from '@/lib/i18n/utils'; +import { validateUsername, validateRegion } from '@/lib/validators'; + +export async function completeOnboarding( + prevState: unknown, + formData: FormData +) { + const t = await getTranslations(); + const { supabase, userId, user } = await getAuthenticatedUser(); + + const username = validateUsername(formData.get('username')); + const region = validateRegion(formData.get('region')); + + if (!username || !region) return { error: t.settings.missingFields }; + + const { data: existing } = await supabase + .from('user_profiles') + .select('user_id') + .ilike('username', username) + .maybeSingle(); + + if (existing && existing.user_id !== userId) + return { error: t.settings.usernameTaken }; + + const meta = user.user_metadata ?? {}; + const fullName = + (typeof meta.full_name === 'string' && meta.full_name) || + (typeof meta.name === 'string' ? meta.name : username); + + const { error: metaError } = await supabase.auth.updateUser({ + data: { username, region, full_name: fullName }, + }); + if (metaError) return { error: metaError.message }; + + const { error: profileError } = await supabase.from('user_profiles').upsert( + { + user_id: userId, + username, + onboarding_completed: true, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id' } + ); + if (profileError?.code === '23505') + return { error: t.settings.usernameTaken }; + if (profileError) return { error: profileError.message }; + + revalidatePath('/', 'layout'); + redirect(localizedHref(await getServerLanguage(), '/dashboard')); +} diff --git a/app/[lang]/onboarding/loading.tsx b/app/[lang]/onboarding/loading.tsx new file mode 100644 index 0000000..447b191 --- /dev/null +++ b/app/[lang]/onboarding/loading.tsx @@ -0,0 +1,24 @@ +import { + Card, + CardContent, + CardHeader, +} from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export default function OnboardingLoading() { + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/app/[lang]/onboarding/page.tsx b/app/[lang]/onboarding/page.tsx new file mode 100644 index 0000000..f7d4d35 --- /dev/null +++ b/app/[lang]/onboarding/page.tsx @@ -0,0 +1,53 @@ +import { redirect } from 'next/navigation'; +import { requireAuth } from '@/lib/auth'; +import { createClient } from '@/lib/supabase/server'; +import { getServerLanguage, getTranslations } from '@/lib/i18n/server'; +import { localizedHref } from '@/lib/i18n/utils'; +import { + ensureUniqueUsername, + needsOnboarding, + suggestUsernameFromMetadata, +} from '@/lib/onboarding'; +import { OnboardingForm } from '@/components/onboarding/OnboardingForm'; + +export async function generateMetadata() { + const t = await getTranslations(); + return { + title: t.onboarding.title, + robots: { + index: false, + follow: false, + googleBot: { index: false, follow: false }, + }, + }; +} + +export default async function OnboardingPage() { + const user = await requireAuth(); + const supabase = await createClient(); + + const { data: profile } = await supabase + .from('user_profiles') + .select('username, onboarding_completed') + .eq('user_id', user.id) + .maybeSingle(); + + if (!needsOnboarding(user.user_metadata, profile?.onboarding_completed)) { + redirect(localizedHref(await getServerLanguage(), '/dashboard')); + } + + const existingUsername = + (typeof user.user_metadata?.username === 'string' && + user.user_metadata.username) || + profile?.username || + null; + + const initialUsername = existingUsername + ? existingUsername + : await ensureUniqueUsername( + supabase, + suggestUsernameFromMetadata(user.user_metadata, user.email) + ); + + return ; +} diff --git a/app/actions/friends.ts b/app/actions/friends.ts index a036e06..9e4a2b4 100644 --- a/app/actions/friends.ts +++ b/app/actions/friends.ts @@ -3,6 +3,7 @@ import { getAuthenticatedUser } from '@/lib/supabase/auth-helpers'; import { createAdminClient } from '@/lib/supabase/server'; import { FRIENDSHIP_COLUMNS } from '@/lib/supabase/columns'; +import { resolveAvatarUrl } from '@/lib/avatar'; import { revalidateProfile } from '@/app/actions/_helpers'; import type { Friendship, @@ -57,7 +58,7 @@ export async function getPendingRequestsWithProfiles(): Promise< const [{ data: profiles }, adminResults] = await Promise.all([ supabase .from('user_profiles') - .select('user_id, username') + .select('user_id, username, avatar_url') .in('user_id', requesterIds), Promise.all( requesterIds.map((id) => adminClient.auth.admin.getUserById(id)) @@ -80,9 +81,10 @@ export async function getPendingRequestsWithProfiles(): Promise< friendship: f, username: profile.username, avatarUrl: - typeof adminUser?.user_metadata?.avatar_url === 'string' - ? adminUser.user_metadata.avatar_url - : undefined, + resolveAvatarUrl( + profile.avatar_url, + adminUser?.user_metadata?.avatar_url + ) ?? undefined, fullName: typeof adminUser?.user_metadata?.full_name === 'string' ? adminUser.user_metadata.full_name @@ -123,7 +125,7 @@ export async function getFriendsWithProfiles( const [{ data: profiles }, adminResults] = await Promise.all([ supabase .from('user_profiles') - .select('user_id, username') + .select('user_id, username, avatar_url') .in('user_id', friendUserIds), Promise.all( friendUserIds.map((id) => adminClient.auth.admin.getUserById(id)) @@ -148,9 +150,10 @@ export async function getFriendsWithProfiles( friendship: f, username: profile.username, avatarUrl: - typeof adminUser?.user_metadata?.avatar_url === 'string' - ? adminUser.user_metadata.avatar_url - : undefined, + resolveAvatarUrl( + profile.avatar_url, + adminUser?.user_metadata?.avatar_url + ) ?? undefined, fullName: typeof adminUser?.user_metadata?.full_name === 'string' ? adminUser.user_metadata.full_name diff --git a/app/actions/playlists.ts b/app/actions/playlists.ts index 63852a7..87b0bc2 100644 --- a/app/actions/playlists.ts +++ b/app/actions/playlists.ts @@ -9,6 +9,7 @@ import { getTranslations } from '@/lib/i18n/server'; import { revalidateProfile } from '@/app/actions/_helpers'; import { parseVisibility, isVisibility } from '@/lib/privacy'; import { getListMediaMetadata } from '@/lib/tmdb'; +import { resolveAvatarUrl } from '@/lib/avatar'; import type { MediaType } from '@/types/tmdb'; import type { Playlist, PrivacyVisibility } from '@/types/profile'; @@ -59,17 +60,17 @@ export async function getPlaylistById(id: string): Promise<{ const [profileResult, ownerAuth] = await Promise.all([ supabase .from('user_profiles') - .select('username') + .select('username, avatar_url') .eq('user_id', data.user_id) .maybeSingle(), adminClient.auth.admin.getUserById(data.user_id), ]); const ownerUsername = profileResult.data?.username ?? null; - const ownerAvatarUrl = - typeof ownerAuth.data.user?.user_metadata?.avatar_url === 'string' - ? ownerAuth.data.user.user_metadata.avatar_url - : null; + const ownerAvatarUrl = resolveAvatarUrl( + profileResult.data?.avatar_url, + ownerAuth.data.user?.user_metadata?.avatar_url + ); return { playlist: data as Playlist, diff --git a/app/api/search/route.ts b/app/api/search/route.ts index dc54b9a..d5e9aa8 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -3,6 +3,7 @@ import { searchMulti } from '@/lib/tmdb'; import { rankMedia } from '@/lib/search/score'; import { getMediaKey } from '@/lib/media'; import { createClient, createAdminClient } from '@/lib/supabase/server'; +import { resolveAvatarUrl } from '@/lib/avatar'; import type { MediaItem } from '@/types/tmdb'; const MAX_RESULTS = 6; @@ -46,7 +47,7 @@ export async function GET(req: NextRequest) { const supabase = await createClient(); const { data: profiles } = await supabase .from('user_profiles') - .select('user_id, username, bio') + .select('user_id, username, bio, avatar_url') .ilike('username', `%${username}%`) .limit(MAX_USER_RESULTS); @@ -58,10 +59,10 @@ export async function GET(req: NextRequest) { const { data } = await adminClient.auth.admin.getUserById( profile.user_id ); - const avatarUrl = - typeof data.user?.user_metadata?.avatar_url === 'string' - ? data.user.user_metadata.avatar_url - : null; + const avatarUrl = resolveAvatarUrl( + profile.avatar_url, + data.user?.user_metadata?.avatar_url + ); return { ...profile, avatar_url: avatarUrl }; }) ); diff --git a/components/navigation/Navbar.tsx b/components/navigation/Navbar.tsx index 809c01b..32b7b91 100644 --- a/components/navigation/Navbar.tsx +++ b/components/navigation/Navbar.tsx @@ -7,13 +7,22 @@ export default async function Navbar() { const t = await getTranslations(); let initialUnreadCount = 0; + let avatarUrl: string | null = null; if (user) { - const { count } = await supabase - .from('notifications') - .select('id', { count: 'exact', head: true }) - .eq('user_id', user.id) - .is('read_at', null); + const [{ count }, { data: profile }] = await Promise.all([ + supabase + .from('notifications') + .select('id', { count: 'exact', head: true }) + .eq('user_id', user.id) + .is('read_at', null), + supabase + .from('user_profiles') + .select('avatar_url') + .eq('user_id', user.id) + .maybeSingle(), + ]); initialUnreadCount = count ?? 0; + avatarUrl = profile?.avatar_url ?? null; } return ( @@ -21,6 +30,7 @@ export default async function Navbar() { user={user} t={t} initialUnreadCount={initialUnreadCount} + avatarUrl={avatarUrl} /> ); } diff --git a/components/navigation/NavbarClient.tsx b/components/navigation/NavbarClient.tsx index 0716256..b10d080 100644 --- a/components/navigation/NavbarClient.tsx +++ b/components/navigation/NavbarClient.tsx @@ -41,12 +41,14 @@ interface NavbarClientProps { user: NavbarUser | null; t: NavbarTranslations; initialUnreadCount: number; + avatarUrl?: string | null; } export function NavbarClient({ user, t, initialUnreadCount, + avatarUrl, }: NavbarClientProps) { const { title, scrolled } = useMediaHeader(); const { lang } = useTranslation(); @@ -173,6 +175,7 @@ export function NavbarClient({ > + + + + {t.onboarding.title} + + {t.onboarding.subtitle} + + +
+ + + + {t.onboarding.usernameLabel} + + + + {t.onboarding.usernameHint} + + + + + {t.onboarding.regionLabel} * + + + + + + + + + + + + + {t.onboarding.regionHint} + + + {state?.error && ( +

+ {state.error} +

+ )} + + + +
+
+
+
+ + ); +} diff --git a/components/settings/DangerZone.tsx b/components/settings/DangerZone.tsx index e39161c..9f30d47 100644 --- a/components/settings/DangerZone.tsx +++ b/components/settings/DangerZone.tsx @@ -24,7 +24,11 @@ const initialState = { success: false, }; -export function DangerZone() { +interface DangerZoneProps { + isOAuthOnly: boolean; +} + +export function DangerZone({ isOAuthOnly }: DangerZoneProps) { const [state, formAction, isPending] = useActionState( deleteAccount, initialState @@ -93,17 +97,26 @@ export function DangerZone() { {t.danger.additionalWarning} - - - {t.settings.dangerZone.confirmPassword} - - - + {isOAuthOnly ? ( + + {t.oauth.deleteNoPassword} + + ) : ( + + + { + t.settings.dangerZone + .confirmPassword + } + + + + )} {state.error && ( diff --git a/components/settings/PasswordSettings.tsx b/components/settings/PasswordSettings.tsx index e5dc723..4b0ef3f 100644 --- a/components/settings/PasswordSettings.tsx +++ b/components/settings/PasswordSettings.tsx @@ -25,7 +25,11 @@ const initialState = { message: '', }; -export function PasswordSettings() { +interface PasswordSettingsProps { + isOAuthOnly: boolean; +} + +export function PasswordSettings({ isOAuthOnly }: PasswordSettingsProps) { const { t } = useTranslation(); const [state, formAction, isPending] = useActionState( updatePassword, @@ -35,9 +39,15 @@ export function PasswordSettings() { return ( - {t.settings.password.title} + + {isOAuthOnly + ? t.oauth.createPasswordTitle + : t.settings.password.title} + - {t.settings.password.description} + {isOAuthOnly + ? t.oauth.createPasswordDescription + : t.settings.password.description} @@ -87,7 +97,9 @@ export function PasswordSettings() { diff --git a/components/settings/ProfileSettings.tsx b/components/settings/ProfileSettings.tsx index 047b42f..c71efe8 100644 --- a/components/settings/ProfileSettings.tsx +++ b/components/settings/ProfileSettings.tsx @@ -33,9 +33,13 @@ const initialState = { interface ProfileSettingsProps { user: User | null; + profileAvatarUrl?: string | null; } -export function ProfileSettings({ user }: ProfileSettingsProps) { +export function ProfileSettings({ + user, + profileAvatarUrl, +}: ProfileSettingsProps) { const { t } = useTranslation(); const [state, formAction, isPending] = useActionState( updateProfile, @@ -47,9 +51,9 @@ export function ProfileSettings({ user }: ProfileSettingsProps) { ); const fileInputRef = useRef(null); const [avatarFile, setAvatarFile] = useState(null); - const [avatarPreview, setAvatarPreview] = useState( - user?.user_metadata?.avatar_url || '' - ); + const currentAvatarUrl = + profileAvatarUrl || user?.user_metadata?.avatar_url || ''; + const [avatarPreview, setAvatarPreview] = useState(currentAvatarUrl); const [fullNameStr, setFullNameStr] = useState( user?.user_metadata?.full_name || '' ); @@ -146,8 +150,7 @@ export function ProfileSettings({ user }: ProfileSettingsProps) { {avatarPreview && avatarPreview !== - user?.user_metadata - ?.avatar_url && ( + currentAvatarUrl && ( <>