feat(auth): restore Google sign-in and AI admin test routes
Google OAuth was implemented locally but never deployed; the login button only renders when AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET are set. Also restores /api/ai/test-* endpoints removed by mistake and wires Google credentials into deploy workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,11 @@ NEXTAUTH_SECRET="changethisinproduction"
|
||||
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
||||
# ADMIN_EMAIL="admin@yourdomain.com"
|
||||
|
||||
# Google OAuth — both required to show "Continue with Google" on /login
|
||||
# Redirect URI in Google Console: {NEXTAUTH_URL}/api/auth/callback/google
|
||||
# AUTH_GOOGLE_ID="....apps.googleusercontent.com"
|
||||
# AUTH_GOOGLE_SECRET="GOCSPX-..."
|
||||
|
||||
# =============================================================================
|
||||
# POSTGRESQL CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
@@ -139,6 +139,8 @@ jobs:
|
||||
SEARXNG_URL: ${{ vars.SEARXNG_URL }}
|
||||
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
|
||||
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
|
||||
AUTH_GOOGLE_ID: ${{ vars.AUTH_GOOGLE_ID }}
|
||||
AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }}
|
||||
run: |
|
||||
ENV_FILE="/opt/memento/.env.docker"
|
||||
touch "$ENV_FILE"
|
||||
@@ -181,6 +183,8 @@ jobs:
|
||||
upsert SEARXNG_URL "$SEARXNG_URL"
|
||||
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
||||
upsert JINA_API_KEY "$JINA_API_KEY"
|
||||
upsert AUTH_GOOGLE_ID "$AUTH_GOOGLE_ID"
|
||||
upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET"
|
||||
|
||||
- name: Deploy on 192.168.1.190
|
||||
env:
|
||||
|
||||
@@ -52,6 +52,8 @@ jobs:
|
||||
SEARXNG_URL: ${{ vars.SEARXNG_URL }}
|
||||
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
|
||||
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
|
||||
AUTH_GOOGLE_ID: ${{ vars.AUTH_GOOGLE_ID }}
|
||||
AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }}
|
||||
run: |
|
||||
ENV_FILE="/opt/memento/.env.docker"
|
||||
touch "$ENV_FILE"
|
||||
@@ -94,6 +96,8 @@ jobs:
|
||||
upsert SEARXNG_URL "$SEARXNG_URL"
|
||||
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
||||
upsert JINA_API_KEY "$JINA_API_KEY"
|
||||
upsert AUTH_GOOGLE_ID "$AUTH_GOOGLE_ID"
|
||||
upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET"
|
||||
|
||||
- name: Deploy (full build, no CI artifact)
|
||||
env:
|
||||
|
||||
@@ -19,6 +19,12 @@ NEXTAUTH_URL="http://localhost:3000"
|
||||
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
||||
# ADMIN_EMAIL="admin@yourdomain.com"
|
||||
|
||||
# Google OAuth (optional — shows "Continue with Google" on login when both are set)
|
||||
# Create credentials: https://console.cloud.google.com/apis/credentials
|
||||
# Authorized redirect URI: {NEXTAUTH_URL}/api/auth/callback/google
|
||||
# AUTH_GOOGLE_ID="....apps.googleusercontent.com"
|
||||
# AUTH_GOOGLE_SECRET="GOCSPX-..."
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Providers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -51,10 +51,19 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
||||
try {
|
||||
const response = await fetch(`/api/ai/test-${type}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
const endTime = Date.now()
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
const bodyPreview = (await response.text()).slice(0, 200)
|
||||
throw new Error(
|
||||
response.status === 404
|
||||
? `Route API introuvable (/api/ai/test-${type}). Redéployez l’application.`
|
||||
: `Réponse non-JSON (HTTP ${response.status}): ${bodyPreview}`,
|
||||
)
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
setResult({
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { LoginForm } from '@/components/login-form';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { isGoogleAuthEnabled } from '@/lib/auth-providers';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const config = await getSystemConfig();
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
return <LoginForm allowRegister={allowRegister} />;
|
||||
return (
|
||||
<LoginForm
|
||||
allowRegister={allowRegister}
|
||||
googleAuthEnabled={isGoogleAuthEnabled()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RegisterForm } from '@/components/register-form';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { isGoogleAuthEnabled } from '@/lib/auth-providers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function RegisterPage() {
|
||||
@@ -10,5 +11,5 @@ export default async function RegisterPage() {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return <RegisterForm />;
|
||||
return <RegisterForm googleAuthEnabled={isGoogleAuthEnabled()} />;
|
||||
}
|
||||
|
||||
59
memento-note/app/api/ai/test-chat/route.ts
Normal file
59
memento-note/app/api/ai/test-chat/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if ((session.user as { role?: string }).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
const testMessage = 'Reply in exactly 3 words: what is your name?'
|
||||
|
||||
const startTime = Date.now()
|
||||
const response = await provider.generateText(testMessage)
|
||||
const endTime = Date.now()
|
||||
|
||||
if (!response || response.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No response from chat provider',
|
||||
provider: config.AI_PROVIDER_CHAT || config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_CHAT || 'granite4:latest',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
provider: config.AI_PROVIDER_CHAT || config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_CHAT || 'granite4:latest',
|
||||
chatResponse: response.trim(),
|
||||
responseTime: endTime - startTime,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const config = await getSystemConfig()
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: message,
|
||||
provider: config.AI_PROVIDER_CHAT || config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_CHAT || 'granite4:latest',
|
||||
stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
97
memento-note/app/api/ai/test-embeddings/route.ts
Normal file
97
memento-note/app/api/ai/test-embeddings/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getEmbeddingsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
function getProviderDetails(config: Record<string, string>, providerType: string) {
|
||||
const provider = providerType.toLowerCase()
|
||||
|
||||
switch (provider) {
|
||||
case 'ollama':
|
||||
return {
|
||||
provider: 'Ollama',
|
||||
baseUrl: config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
}
|
||||
case 'openai':
|
||||
return {
|
||||
provider: 'OpenAI',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small',
|
||||
}
|
||||
case 'custom':
|
||||
return {
|
||||
provider: 'Custom OpenAI',
|
||||
baseUrl: config.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || 'Not configured',
|
||||
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
provider: providerType,
|
||||
baseUrl: 'unknown',
|
||||
model: config.AI_MODEL_EMBEDDING || 'unknown',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if ((session.user as { role?: string }).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getEmbeddingsProvider(config)
|
||||
|
||||
const startTime = Date.now()
|
||||
const embeddings = await provider.getEmbeddings('test')
|
||||
const endTime = Date.now()
|
||||
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
|
||||
if (!embeddings || embeddings.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No embeddings returned',
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
details,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
embeddingLength: embeddings.length,
|
||||
firstValues: embeddings.slice(0, 5),
|
||||
responseTime: endTime - startTime,
|
||||
details,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const config = await getSystemConfig()
|
||||
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
|
||||
const details = getProviderDetails(config, providerType)
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: message,
|
||||
provider: providerType,
|
||||
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
|
||||
details,
|
||||
stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
60
memento-note/app/api/ai/test-tags/route.ts
Normal file
60
memento-note/app/api/ai/test-tags/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if ((session.user as { role?: string }).role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const testContent =
|
||||
'This is a test note about artificial intelligence and machine learning. It contains keywords like AI, ML, neural networks, and deep learning.'
|
||||
|
||||
const startTime = Date.now()
|
||||
const tags = await provider.generateTags(testContent)
|
||||
const endTime = Date.now()
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No tags generated',
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest',
|
||||
tags,
|
||||
responseTime: endTime - startTime,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const config = await getSystemConfig()
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: message,
|
||||
provider: config.AI_PROVIDER_TAGS || 'ollama',
|
||||
model: config.AI_MODEL_TAGS || 'granite4:latest',
|
||||
stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,60 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { authConfig } from './auth.config';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import { z } from 'zod';
|
||||
import prisma from '@/lib/prisma';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { buildAuthProviders } from '@/lib/auth-providers';
|
||||
|
||||
export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
Credentials({
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
const parsedCredentials = z
|
||||
.object({ email: z.string().email(), password: z.string().min(6) })
|
||||
.safeParse(credentials);
|
||||
|
||||
if (!parsedCredentials.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { email, password } = parsedCredentials.data;
|
||||
|
||||
const { allowed } = rateLimit(`login:${email.toLowerCase()}`)
|
||||
if (!allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() }
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: buildAuthProviders(),
|
||||
events: {
|
||||
async createUser({ user }) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||
if (!adminEmail || !user.id || user.email?.toLowerCase() !== adminEmail) {
|
||||
return;
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { role: 'ADMIN', emailVerified: new Date() },
|
||||
});
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider === 'google' && user.email) {
|
||||
const email = user.email.toLowerCase();
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing && adminEmail && email === adminEmail && existing.role !== 'ADMIN') {
|
||||
await prisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { role: 'ADMIN', emailVerified: new Date() },
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (passwordsMatch) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
return true;
|
||||
},
|
||||
async jwt({ token, user }) {
|
||||
if (user?.id) {
|
||||
token.id = user.id;
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { role: true },
|
||||
});
|
||||
token.role = dbUser?.role ?? 'USER';
|
||||
} else if (token.sub) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.sub },
|
||||
select: { role: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
token.id = token.sub;
|
||||
token.role = dbUser.role;
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
21
memento-note/components/google-sign-in-button.tsx
Normal file
21
memento-note/components/google-sign-in-button.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { signIn } from 'next-auth/react';
|
||||
|
||||
export function GoogleSignInButton({ label }: { label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn('google', { callbackUrl: '/home' })}
|
||||
className="w-full flex items-center justify-center gap-3 py-4 rounded-2xl border border-[var(--border)] bg-white dark:bg-white/5 text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/10 transition-all"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden>
|
||||
<path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303C33.654 32.657 29.203 36 24 36c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C33.64 6.053 28.991 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z" />
|
||||
<path fill="#FF3D00" d="m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C33.64 6.053 28.991 4 24 4 16.318 4 9.656 8.337 6.306 14.691z" />
|
||||
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238C29.211 35.091 26.715 36 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z" />
|
||||
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z" />
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,19 @@ import { authenticate } from '@/app/actions/auth';
|
||||
import Link from 'next/link';
|
||||
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { GoogleSignInButton } from '@/components/google-sign-in-button';
|
||||
|
||||
function AuthDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="relative flex items-center py-2">
|
||||
<div className="flex-grow border-t border-[var(--border)]" />
|
||||
<span className="mx-4 text-[10px] uppercase tracking-widest text-[var(--muted-foreground)] font-bold">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-grow border-t border-[var(--border)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginButton() {
|
||||
const { pending } = useFormStatus();
|
||||
@@ -28,7 +41,13 @@ function LoginButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginForm({ allowRegister = true }: { allowRegister?: boolean }) {
|
||||
export function LoginForm({
|
||||
allowRegister = true,
|
||||
googleAuthEnabled = false,
|
||||
}: {
|
||||
allowRegister?: boolean;
|
||||
googleAuthEnabled?: boolean;
|
||||
}) {
|
||||
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
||||
const { t } = useLanguage();
|
||||
|
||||
@@ -44,6 +63,13 @@ export function LoginForm({ allowRegister = true }: { allowRegister?: boolean })
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{googleAuthEnabled && (
|
||||
<>
|
||||
<GoogleSignInButton label={t('auth.continueWithGoogle')} />
|
||||
<AuthDivider label={t('auth.orContinueWith')} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<form action={dispatch} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { register } from '@/app/actions/register';
|
||||
import Link from 'next/link';
|
||||
import { Mail, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { GoogleSignInButton } from '@/components/google-sign-in-button';
|
||||
|
||||
function RegisterButton() {
|
||||
const { pending } = useFormStatus();
|
||||
@@ -28,7 +29,19 @@ function RegisterButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterForm() {
|
||||
function AuthDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="relative flex items-center py-2">
|
||||
<div className="flex-grow border-t border-[var(--border)]" />
|
||||
<span className="mx-4 text-[10px] uppercase tracking-widest text-[var(--muted-foreground)] font-bold">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-grow border-t border-[var(--border)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?: boolean }) {
|
||||
const [errorMessage, dispatch] = useActionState(register, undefined);
|
||||
const { t } = useLanguage();
|
||||
|
||||
@@ -44,6 +57,13 @@ export function RegisterForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{googleAuthEnabled && (
|
||||
<>
|
||||
<GoogleSignInButton label={t('auth.continueWithGoogle')} />
|
||||
<AuthDivider label={t('auth.orContinueWith')} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<form action={dispatch} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||
|
||||
72
memento-note/lib/auth-providers.ts
Normal file
72
memento-note/lib/auth-providers.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import Google from 'next-auth/providers/google';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
|
||||
export function buildAuthProviders() {
|
||||
const providers = [];
|
||||
|
||||
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
|
||||
providers.push(
|
||||
Google({
|
||||
clientId: process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
providers.push(
|
||||
Credentials({
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
const parsedCredentials = z
|
||||
.object({ email: z.string().email(), password: z.string().min(6) })
|
||||
.safeParse(credentials);
|
||||
|
||||
if (!parsedCredentials.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { email, password } = parsedCredentials.data;
|
||||
|
||||
const { allowed } = rateLimit(`login:${email.toLowerCase()}`);
|
||||
if (!allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (passwordsMatch) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function isGoogleAuthEnabled() {
|
||||
return Boolean(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"createAccount": "Create your account",
|
||||
"rememberMe": "Remember me",
|
||||
"orContinueWith": "Or continue with",
|
||||
"continueWithGoogle": "Continue with Google",
|
||||
"checkYourEmail": "Check your email",
|
||||
"resetEmailSent": "We have sent a password reset link to your email address if it exists in our system.",
|
||||
"returnToLogin": "Return to Login",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"createAccount": "Créez votre compte",
|
||||
"rememberMe": "Se souvenir de moi",
|
||||
"orContinueWith": "Ou continuer avec",
|
||||
"continueWithGoogle": "Continuer avec Google",
|
||||
"checkYourEmail": "Vérifiez votre email",
|
||||
"resetEmailSent": "Nous avons envoyé un lien de réinitialisation à votre adresse email si elle existe dans notre système.",
|
||||
"returnToLogin": "Retour à la connexion",
|
||||
|
||||
Reference in New Issue
Block a user