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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
TMDB_READ_ACCESS_TOKEN=your-tmdb-read-access-token
NEXT_PUBLIC_BASE_URL=http://localhost:3000
WATCHMODE_API_KEY=your-watchmode-api-key
NEXT_PUBLIC_SENTRY_DSN=http://your-bugsink-dsn@your-bugsink-host/1
SENTRY_DSN=http://your-bugsink-dsn@your-bugsink-host/1
TEST_USER_EMAIL=test@email.com
TEST_USER_PASSWORD=your-test-user-password
32 changes: 20 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

---

<a name="-english"></a>
`<a name="-english"></a>`

## 🇬🇧 English

Expand Down Expand Up @@ -67,12 +67,16 @@ pnpm install
**3. Environment variables** — create `.env.local`:

```bash
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token
WATCHMODE_API_KEY=your_watchmode_api_key
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
TMDB_READ_ACCESS_TOKEN=your-tmdb-read-access-token
NEXT_PUBLIC_BASE_URL=http://localhost:3000
WATCHMODE_API_KEY=your-watchmode-api-key
NEXT_PUBLIC_SENTRY_DSN=http://your-bugsink-dsn@your-bugsink-host/1
SENTRY_DSN=http://your-bugsink-dsn@your-bugsink-host/1
TEST_USER_EMAIL=test@email.com
TEST_USER_PASSWORD=your-test-user-password
```

**4. Database setup**
Expand Down Expand Up @@ -144,7 +148,7 @@ watchlist/

---

<a name="-français"></a>
`<a name="-français"></a>`

## 🇫🇷 Français

Expand Down Expand Up @@ -193,12 +197,16 @@ pnpm install
**3. Variables d'environnement** — crée un `.env.local` :

```bash
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token
WATCHMODE_API_KEY=your_watchmode_api_key
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
TMDB_READ_ACCESS_TOKEN=your-tmdb-read-access-token
NEXT_PUBLIC_BASE_URL=http://localhost:3000
WATCHMODE_API_KEY=your-watchmode-api-key
NEXT_PUBLIC_SENTRY_DSN=http://your-bugsink-dsn@your-bugsink-host/1
SENTRY_DSN=http://your-bugsink-dsn@your-bugsink-host/1
TEST_USER_EMAIL=test@email.com
TEST_USER_PASSWORD=your-test-user-password
```

**4. Base de données** : Exécute les fichiers `supabase/migrations/*.sql` dans l'ordre depuis ton dashboard Supabase.
Expand Down
24 changes: 22 additions & 2 deletions app/[lang]/(protected)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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}</>;
}
6 changes: 3 additions & 3 deletions app/[lang]/(protected)/profile/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
73 changes: 47 additions & 26 deletions app/[lang]/(protected)/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions app/[lang]/(protected)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,6 +48,7 @@ async function SettingsSection({ user }: { user: User }) {
user={user}
userProfile={userProfile}
privacySettings={privacySettings}
isOAuthOnly={isOAuthOnly(user)}
/>
);
}
Expand Down
56 changes: 56 additions & 0 deletions app/[lang]/onboarding/actions.ts
Original file line number Diff line number Diff line change
@@ -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'));
}
20 changes: 20 additions & 0 deletions app/[lang]/onboarding/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';

export default function OnboardingLoading() {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<Card className="w-full max-w-md">
<CardHeader className="space-y-2 text-center">
<Skeleton className="mx-auto h-6 w-44" />
<Skeleton className="mx-auto h-4 w-64" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
);
}
53 changes: 53 additions & 0 deletions app/[lang]/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <OnboardingForm initialUsername={initialUsername} />;
}
Loading