d3ro-voice/apps/web/src/app/login/page.tsx
2026-08-29 18:33:45 +09:00

446 lines
16 KiB
TypeScript

'use client'
// apps/web/src/app/login/page.tsx
// D3RO-VOICE Canonical Login Page — Precision Voice Intelligence Design
// Design Reference: docs/v3/designs/login.html
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { d3roPalette } from '@d3ro/ui/theme'
import { Box, Stack, Alert } from '@mui/material'
import { useI18n } from '@d3ro/i18n'
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
import { useAuth } from '@/components/providers/auth-provider'
export default function LoginPage(): React.ReactElement {
const router = useRouter()
const { user, loading } = useAuth()
const { t } = useI18n()
const [error, setError] = useState<string | null>(null)
const [signingIn, setSigningIn] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const configured = isSupabaseConfigured()
useEffect(() => {
if (!loading && user) {
router.replace('/dashboard')
}
}, [loading, user, router])
async function handleOAuth(provider: 'google' | 'github' | 'apple'): Promise<void> {
if (!configured) {
setError('Supabase 환경변수가 설정되지 않았습니다.')
return
}
setError(null)
setSigningIn(true)
try {
const supabase = getSupabaseBrowserClient()
const redirectTo = `${window.location.origin}/auth/callback`
const { error: err } = await supabase.auth.signInWithOAuth({
provider: provider === 'apple' ? 'apple' : provider,
options: { redirectTo }
})
if (err) {
setError(err.message)
setSigningIn(false)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error')
setSigningIn(false)
}
}
async function handleEmailSignIn(e: React.FormEvent): Promise<void> {
e.preventDefault()
if (!configured) {
setError('Supabase 환경변수가 설정되지 않았습니다.')
return
}
if (!email || !password) return
setError(null)
setSigningIn(true)
try {
const supabase = getSupabaseBrowserClient()
const { error: err } = await supabase.auth.signInWithPassword({ email, password })
if (err) {
setError(err.message)
setSigningIn(false)
} else {
router.replace('/dashboard')
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error')
setSigningIn(false)
}
}
return (
<Box
sx={{
minHeight: '100dvh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: d3roPalette.bg.app,
p: 2,
fontFamily: '"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo", "Noto Sans KR", sans-serif'
}}
>
<Box
sx={{
width: '100%',
maxWidth: 400,
bgcolor: d3roPalette.bg.app,
border: '1px solid var(--d3-border-default)',
borderRadius: '24px',
p: { xs: 3.5, sm: 4.5 },
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7)',
color: d3roPalette.text.secondary
}}
>
{/* Header & LED Indicators */}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 2 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: d3roPalette.accent.main,
boxShadow: '0 0 10px var(--d3-accent-main)',
animation: 'pulse 2s infinite ease-in-out',
'@keyframes pulse': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.6, transform: 'scale(0.92)' }
}
}}
/>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: d3roPalette.tag.green,
boxShadow: '0 0 10px var(--d3-tag-green)'
}}
/>
</Box>
<Box
component="h1"
sx={{
m: 0,
fontSize: '22px',
fontWeight: 500,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
letterSpacing: '0.25em',
color: d3roPalette.text.primary,
textIndent: '0.25em'
}}
>
D3RO-VOICE
</Box>
<Box
sx={{
mt: 1,
fontSize: '9px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
letterSpacing: '0.18em',
color: d3roPalette.text.label,
textTransform: 'uppercase'
}}
>
{t('login.subtitle') ?? 'Precision Voice Intelligence'}
</Box>
</Box>
{error && (
<Alert
severity="error"
variant="outlined"
sx={{
mb: 3,
bgcolor: 'color-mix(in srgb, var(--d3-tag-red) 8%, transparent)',
borderColor: 'color-mix(in srgb, var(--d3-tag-red) 30%, transparent)',
color: d3roPalette.tag.red,
fontSize: 12
}}
>
{error}
</Alert>
)}
{/* OAuth Buttons */}
<Stack spacing={1.5} sx={{ mb: 3 }}>
{/* Google Button */}
<Box
component="button"
type="button"
disabled={signingIn}
onClick={() => void handleOAuth('google')}
sx={{
width: '100%',
bgcolor: d3roPalette.bg.card,
border: '1px solid var(--d3-border-default)',
borderRadius: '12px',
py: 1.6,
px: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
color: d3roPalette.text.primary,
fontSize: '14px',
fontWeight: 500,
cursor: signingIn ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: d3roPalette.bg.elevated,
borderColor: d3roPalette.border.strong
},
'&:active': {
transform: 'scale(0.99)'
}
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
</svg>
{t('login.google') ?? 'Google로 로그인'}
</Box>
{/* Apple Button */}
<Box
component="button"
type="button"
disabled={signingIn}
onClick={() => void handleOAuth('apple')}
sx={{
width: '100%',
bgcolor: d3roPalette.bg.card,
border: '1px solid var(--d3-border-default)',
borderRadius: '12px',
py: 1.6,
px: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
color: d3roPalette.text.primary,
fontSize: '14px',
fontWeight: 500,
cursor: signingIn ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: d3roPalette.bg.elevated,
borderColor: d3roPalette.border.strong
},
'&:active': {
transform: 'scale(0.99)'
}
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.4,8.3c-0.4-2.2,1.3-4.1,3.2-4.5c-0.5-2.2-2.3-3.6-4.5-3.6c-1.8-0.2-3.8,1.2-4.8,1.2c-1,0-2.6-1.1-4.1-1.1c-2,0-4,1.1-5,2.9C-1.8,7.2,0.6,13,2.6,15.9c1,1.4,2.2,3.1,3.8,3c1.5-0.1,2.1-1,3.9-1c1.8,0,2.3,1,3.9,1c1.6,0,2.6-1.5,3.6-2.9C18.9,14.4,19.3,13,19.3,12.9C19.2,12.8,15.9,11.5,15.4,8.3z M12.8,3.2c0.8-1,1.4-2.5,1.2-3.9C12.8,0.1,11.3,0.8,10.5,1.8C9.8,2.7,9.2,4.2,9.4,5.6C10.8,5.7,12,4.5,12.8,3.2z" />
</svg>
Apple로
</Box>
{/* GitHub Button */}
<Box
component="button"
type="button"
disabled={signingIn}
onClick={() => void handleOAuth('github')}
sx={{
width: '100%',
bgcolor: d3roPalette.bg.card,
border: '1px solid var(--d3-border-default)',
borderRadius: '12px',
py: 1.6,
px: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
color: d3roPalette.text.primary,
fontSize: '14px',
fontWeight: 500,
cursor: signingIn ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: d3roPalette.bg.elevated,
borderColor: d3roPalette.border.strong
},
'&:active': {
transform: 'scale(0.99)'
}
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{t('login.github') ?? 'GitHub로 로그인'}
</Box>
</Stack>
{/* Divider */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.default }} />
<Box
sx={{
fontSize: '10px',
fontFamily: 'ui-monospace, monospace',
color: d3roPalette.text.label,
letterSpacing: '0.1em'
}}
>
{t('login.or') ?? '또는'}
</Box>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.default }} />
</Box>
{/* Email & Password Form */}
<Box component="form" onSubmit={handleEmailSignIn}>
<Stack spacing={2} sx={{ mb: 3 }}>
<Box>
<Box
component="label"
sx={{
display: 'block',
fontSize: '10px',
fontFamily: 'ui-monospace, monospace',
letterSpacing: '0.15em',
color: d3roPalette.text.label,
mb: 0.75,
pl: 0.5
}}
>
EMAIL
</Box>
<Box
component="input"
type="email"
required
placeholder="user@studio.com"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
sx={{
width: '100%',
bgcolor: d3roPalette.bg.app,
border: '1px solid var(--d3-border-default)',
borderRadius: '12px',
py: 1.6,
px: 2,
fontSize: '14px',
color: d3roPalette.text.primary,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.15s ease',
'&:focus': {
borderColor: d3roPalette.accent.main
},
'&::placeholder': {
color: 'color-mix(in srgb, var(--d3-text-label) 40%, transparent)'
}
}}
/>
</Box>
<Box>
<Box
component="label"
sx={{
display: 'block',
fontSize: '10px',
fontFamily: 'ui-monospace, monospace',
letterSpacing: '0.15em',
color: d3roPalette.text.label,
mb: 0.75,
pl: 0.5
}}
>
PASSWORD
</Box>
<Box
component="input"
type="password"
required
placeholder="••••••••"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
sx={{
width: '100%',
bgcolor: d3roPalette.bg.app,
border: '1px solid var(--d3-border-default)',
borderRadius: '12px',
py: 1.6,
px: 2,
fontSize: '14px',
color: d3roPalette.text.primary,
outline: 'none',
boxSizing: 'border-box',
transition: 'border-color 0.15s ease',
'&:focus': {
borderColor: d3roPalette.accent.main
},
'&::placeholder': {
color: 'color-mix(in srgb, var(--d3-text-label) 40%, transparent)'
}
}}
/>
</Box>
</Stack>
{/* Primary Orange CTA Button */}
<Box
component="button"
type="submit"
disabled={signingIn}
sx={{
width: '100%',
bgcolor: d3roPalette.accent.main,
border: 'none',
borderRadius: '12px',
py: 1.75,
fontSize: '14px',
fontWeight: 500,
letterSpacing: '0.05em',
color: d3roPalette.bg.app,
cursor: signingIn ? 'not-allowed' : 'pointer',
boxShadow: '0 0 25px rgba(59, 130, 246, 0.35)',
transition: 'all 0.15s ease',
'&:hover': {
filter: 'brightness(1.08)',
boxShadow: '0 0 30px rgba(59, 130, 246, 0.5)'
},
'&:active': {
transform: 'scale(0.99)'
}
}}
>
{signingIn ? '로그인 중...' : '로그인'}
</Box>
</Box>
{/* Footer */}
<Box sx={{ mt: 3.5, textAlign: 'center', fontSize: '11px', color: d3roPalette.text.label }}>
?{' '}
<Box
component="span"
sx={{
color: d3roPalette.accent.main,
cursor: 'pointer',
fontWeight: 600,
'&:hover': { textDecoration: 'underline' }
}}
>
</Box>
</Box>
</Box>
</Box>
)
}