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
198 changes: 198 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# ============================================================
# ECOWOODS — Environment Variables
# Copy to .env.local and fill in your values.
# NEVER commit .env.local to git.
# ============================================================

# ──────────────────────────────────────────────────────────────
# DATABASE (PostgreSQL)
# ──────────────────────────────────────────────────────────────
# Recommended: Supabase (https://supabase.com)
# 1. Create a new Supabase project
# 2. Project Settings → Database → Connection string → URI mode
# 3. Copy the "Connection string" (with your password)
#
# Alternative: Neon (https://neon.tech) — excellent for serverless
# Neon URL format: postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/dbname?sslmode=require
#
# Local development: docker-compose up (see docker-compose.yml)
DATABASE_URL="postgresql://ecowoods:ecowoods_secret@localhost:5432/ecowoods_db"

# For Supabase connection pooling (Prisma Accelerate or pgBouncer)
# Use this for serverless deployments to avoid connection exhaustion:
# DIRECT_URL="postgresql://..." # Direct connection (for migrations)

# ──────────────────────────────────────────────────────────────
# AUTH.JS v5 (next-auth)
# ──────────────────────────────────────────────────────────────
# Generate a strong secret: openssl rand -base64 32
NEXTAUTH_SECRET="change-me-generate-with-openssl-rand-base64-32"

# Your production domain (no trailing slash)
NEXTAUTH_URL="http://localhost:3000"
# Production: NEXTAUTH_URL="https://ecowoods.ca"

# ──────────────────────────────────────────────────────────────
# SOCIAL LOGIN (OAuth) — 소셜 로그인
# 설정된 변수만 활성화됩니다. 빈 값이면 해당 버튼은 숨겨집니다.
# Only providers with both ID + SECRET configured will be shown.
# ──────────────────────────────────────────────────────────────

# ── Google OAuth ───────────────────────────────────────────────
# 설정 방법:
# 1. console.cloud.google.com → 새 프로젝트 생성
# 2. 사용자 인증 정보 → OAuth 2.0 클라이언트 ID 생성
# 3. 애플리케이션 유형: "웹 애플리케이션"
# 4. 승인된 리디렉션 URI에 추가:
# http://localhost:3000/api/auth/callback/google (개발)
# https://ecowoods.ca/api/auth/callback/google (프로덕션)
# 5. 클라이언트 ID / 클라이언트 보안 비밀번호 복사
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""

# ── Facebook OAuth ─────────────────────────────────────────────
# 설정 방법:
# 1. developers.facebook.com → 내 앱 → 앱 만들기
# 2. Facebook 로그인 제품 추가
# 3. 설정 → 유효한 OAuth 리디렉션 URI 추가:
# https://ecowoods.ca/api/auth/callback/facebook
# 4. 앱 ID / 앱 시크릿 코드 복사
# 5. 앱 검수 필요 (Facebook 정책 변경 후 공개 앱은 검수 필요)
AUTH_FACEBOOK_ID=""
AUTH_FACEBOOK_SECRET=""

# ── Apple Sign In ──────────────────────────────────────────────
# 설정 방법 (가장 복잡):
# 1. developer.apple.com → Certificates, IDs & Profiles
# 2. Identifiers → App IDs → Sign In with Apple 체크
# 3. Identifiers → Services IDs 생성 (예: com.ecowoods.web)
# → Domains: ecowoods.ca
# → Return URLs: https://ecowoods.ca/api/auth/callback/apple
# 4. Keys → Key 생성 → Sign In with Apple 체크 → 다운로드
# 5. 아래 값 채우기
AUTH_APPLE_ID="" # Services ID (예: com.ecowoods.web)
AUTH_APPLE_SECRET="" # 개인 키 파일 내용 (-----BEGIN PRIVATE KEY----- ...)
AUTH_APPLE_TEAM_ID="" # Apple Developer Team ID (10자리 영숫자)
AUTH_APPLE_KEY_ID="" # Key ID (10자리 영숫자)

# ──────────────────────────────────────────────────────────────
# EMAIL — Option A: Resend (권장)
# ──────────────────────────────────────────────────────────────
# resend.com 가입 → API Keys 생성
# 개발 테스트용: RESEND_FROM_EMAIL=onboarding@resend.dev 그대로 사용 가능
# (도메인 인증 불필요, Resend가 제공하는 샌드박스 주소)
RESEND_API_KEY="re_xxxxxxxxxx"
RESEND_FROM_EMAIL="onboarding@resend.dev"
ADMIN_EMAIL="your-admin@example.com"

# ──────────────────────────────────────────────────────────────
# EMAIL — Option B: SMTP (Gmail 등) — Resend 대신 사용
# ──────────────────────────────────────────────────────────────
# Gmail 사용 시:
# 1. Google 계정 → 보안 → 2단계 인증 활성화 (필수)
# 2. Google 계정 → 보안 → 앱 비밀번호 생성
# (앱: "메일", 기기: "기타" → "Ecowoods" 입력)
# 3. 생성된 16자리 비밀번호를 SMTP_PASS에 입력
# pnpm add nodemailer --filter @ecowoods/web 도 실행 필요
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
SMTP_USER="your.gmail@gmail.com"
SMTP_PASS="xxxx xxxx xxxx xxxx"
SMTP_FROM="your.gmail@gmail.com"

# ──────────────────────────────────────────────────────────────
# STRIPE (Payments)
# ──────────────────────────────────────────────────────────────
# See full setup guide in lib/stripe.ts
#
# Get keys from: https://dashboard.stripe.com/apikeys
# Use TEST keys for development, LIVE keys for production
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."

# Webhook signing secret — get from Stripe Dashboard → Webhooks
# For local testing: stripe listen --forward-to localhost:3000/api/webhooks/stripe
STRIPE_WEBHOOK_SECRET="whsec_..."

# ──────────────────────────────────────────────────────────────
# EMAIL (Resend)
# ──────────────────────────────────────────────────────────────
# Sign up at: https://resend.com (3,000 emails/month free)
# Docs: https://resend.com/docs/introduction
#
# Setup steps:
# 1. Add your domain (ecowoods.ca) in Resend → Domains
# 2. Add SPF, DKIM, DMARC DNS records (shown in Resend dashboard)
# 3. Wait for verification (usually 5-15 minutes)
# 4. Copy your API key below
RESEND_API_KEY="re_..."

# The "From" address for outgoing emails (must be on a verified domain)
RESEND_FROM_EMAIL="hello@ecowoods.ca"

# Admin notification email (receives new quotes, inquiries, etc.)
ADMIN_EMAIL="admin@ecowoods.ca"

# ──────────────────────────────────────────────────────────────
# FILE STORAGE (PDFs — Vercel Blob)
# ──────────────────────────────────────────────────────────────
# Vercel Blob: https://vercel.com/docs/storage/vercel-blob
# In Vercel Dashboard → Storage → Create Blob Store → Connect to project
# The token is auto-injected in Vercel deployments.
# For local development: vercel env pull .env.local
BLOB_READ_WRITE_TOKEN="vercel_blob_rw_..."

# Alternative: Supabase Storage
# SUPABASE_URL="https://xxx.supabase.co"
# SUPABASE_ANON_KEY="eyJ..."
# SUPABASE_SERVICE_ROLE_KEY="eyJ..." # For server-side uploads

# ──────────────────────────────────────────────────────────────
# AI FEATURES (Optional — OpenAI)
# ──────────────────────────────────────────────────────────────
# When set AND aiEnabled=true in admin Settings, enables:
# · AI-suggested replies to quote requests
# · AI-assisted contract scope writing
# · AI draft for inquiry replies
#
# Get key from: https://platform.openai.com/api-keys
# Recommended model: gpt-4o-mini (fast + cheap)
OPENAI_API_KEY="" # Leave blank to disable AI features

# ──────────────────────────────────────────────────────────────
# LEAD CAPTURE (Webhook — Optional)
# ──────────────────────────────────────────────────────────────
# Optional webhook to forward new leads to n8n, Zapier, Make, etc.
# POST body: { leadId, name, email, phone, city, service, ... }
LEADS_WEBHOOK_URL=""

# ──────────────────────────────────────────────────────────────
# PLAID (Future — Bank Deposit Detection)
# ──────────────────────────────────────────────────────────────
# NOT needed for MVP. Uncomment when ready to auto-detect e-transfers.
#
# Plaid supports Canadian banks: TD, RBC, BMO, Scotiabank, CIBC.
# Requirements: Plaid Business account, business bank account, customer consent.
# Full proposal: see admin/settings for implementation notes.
#
# PLAID_CLIENT_ID=""
# PLAID_SECRET="" # Use sandbox secret for testing
# PLAID_ACCESS_TOKEN="" # Generated after linking your bank account
# PLAID_ENV="sandbox" # "sandbox" | "development" | "production"

# ──────────────────────────────────────────────────────────────
# DEPLOYMENT (Vercel)
# ──────────────────────────────────────────────────────────────
# These are automatically set by Vercel — don't set them manually:
# VERCEL_URL, VERCEL_ENV, VERCEL_GIT_COMMIT_SHA
#
# Add all variables above to Vercel Dashboard:
# Project → Settings → Environment Variables
# Use different values for Preview and Production environments.
#
# Recommended Vercel setup:
# - Connect GitHub repo for automatic deployments
# - Set DATABASE_URL to your hosted Postgres (Supabase or Neon)
# - Set NEXTAUTH_URL to your production domain
# - Set STRIPE_*_KEY to LIVE keys in production only
# - Set STRIPE_WEBHOOK_SECRET from Stripe Dashboard → Webhooks
147 changes: 147 additions & 0 deletions apps/web/app/(auth)/login/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use client';

import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { signIn, getSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import SocialLoginButtons from '@/app/components/SocialLoginButtons';
import type { OAuthProvider } from '@/lib/auth-providers';

const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(1, 'Password is required'),
});
type LoginForm = z.infer<typeof loginSchema>;

export default function LoginForm({ enabledProviders }: { enabledProviders: OAuthProvider[] }) {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl');
const [isLoading, setIsLoading] = useState(false);

const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({ resolver: zodResolver(loginSchema) });

const onSubmit = async (data: LoginForm) => {
setIsLoading(true);
try {
const result = await signIn('credentials', {
email: data.email,
password: data.password,
redirect: false,
});

if (result?.error) {
toast.error('Invalid email or password. Please try again.');
return;
}

toast.success('Welcome back!');
// If there's an explicit callbackUrl, use it; otherwise route by role
if (callbackUrl) {
router.push(callbackUrl);
} else {
const session = await getSession();
router.push(session?.user?.role === 'ADMIN' ? '/admin' : '/mypage');
}
router.refresh();
} catch (err) {
console.error('[login] error:', err);
toast.error('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
};

return (
<div className="auth-page">
<div className="auth-card">
{/* Brand */}
<div className="auth-brand">
<span className="brand-mark" style={{ width: 44, height: 44 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M12 2C9 6 6 8 6 12c0 3.5 2.5 6 6 6s6-2.5 6-6c0-4-3-6-6-10Z" fill="currentColor" fillOpacity="0.18" />
<path d="M12 4.5c-2 3-4 4.5-4 7.5 0 2.5 1.8 4.5 4 4.5" strokeLinecap="round" />
</svg>
</span>
<div>
<div style={{ fontWeight: 700, fontSize: '1.15rem' }}>Ecowoods</div>
<div style={{ fontSize: '0.78rem', color: 'var(--muted)' }}>Customer Portal</div>
</div>
</div>

<h1 className="auth-title">Sign in to your account</h1>
<p className="auth-sub">Access your quotes, projects, and invoices.</p>

{/* Social login */}
{enabledProviders.length > 0 && (
<>
<SocialLoginButtons enabledProviders={enabledProviders} callbackUrl={callbackUrl ?? '/mypage'} mode="login" />
<div className="auth-or-divider"><span>or sign in with email</span></div>
</>
)}

{/* Email form */}
<form onSubmit={handleSubmit(onSubmit)} noValidate className="auth-form">
<div className="field">
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
className={errors.email ? 'field-error' : ''}
{...register('email')}
/>
{errors.email && <p className="error-message">{errors.email.message}</p>}
</div>

<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className={errors.password ? 'field-error' : ''}
{...register('password')}
/>
{errors.password && <p className="error-message">{errors.password.message}</p>}
</div>

<button
type="submit"
className="btn btn-copper btn-lg"
style={{ width: '100%', marginTop: '0.5rem' }}
disabled={isLoading}
>
{isLoading ? 'Signing in…' : 'Sign in'}
</button>
</form>

<p className="auth-footer">
Don&apos;t have an account?{' '}
<Link href="/register">Create one — it&apos;s free</Link>
</p>

{/* Demo credentials (dev only) */}
{process.env.NODE_ENV === 'development' && (
<>
<div className="auth-divider"><span>Demo credentials</span></div>
<div className="auth-demo-creds">
<p><strong>Customer:</strong> sarah.miller@gmail.com / Customer2026!</p>
<p><strong>Admin:</strong> admin@ecowoods.ca / Admin2026!</p>
</div>
</>
)}
</div>
</div>
);
}
12 changes: 12 additions & 0 deletions apps/web/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Suspense } from 'react';
import { getEnabledProviders } from '@/lib/auth-providers';
import LoginForm from './LoginForm';

export default function LoginPage() {
const enabledProviders = getEnabledProviders();
return (
<Suspense>
<LoginForm enabledProviders={enabledProviders} />
</Suspense>
);
}
Loading
Loading