merge develop into release
This commit is contained in:
@@ -72,6 +72,12 @@ interface SidebarProps {
|
||||
launcherUpdateMessage: string
|
||||
onLauncherUpdate: () => void
|
||||
authAccessToken: string
|
||||
tailscaleIssue?: {
|
||||
state?: 'checking' | 'warning'
|
||||
title: string
|
||||
message: string
|
||||
} | null
|
||||
onTailscaleRetry?: () => void
|
||||
}
|
||||
|
||||
type UserModal = 'profile' | 'settings' | 'platforms' | null
|
||||
@@ -99,7 +105,7 @@ function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastLoginAt, primaryAffiliation, currentLicense, activeApp, onAppChange, onLogout, collapsed, onToggleCollapse, launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, authAccessToken }: SidebarProps): React.JSX.Element {
|
||||
function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastLoginAt, primaryAffiliation, currentLicense, activeApp, onAppChange, onLogout, collapsed, onToggleCollapse, launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, authAccessToken, tailscaleIssue, onTailscaleRetry }: SidebarProps): React.JSX.Element {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [modal, setModal] = useState<UserModal>(null)
|
||||
const [avatarVersion, setAvatarVersion] = useState(() => Date.now())
|
||||
@@ -236,6 +242,51 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
|
||||
|
||||
{/* 유저 프로필 푸터 */}
|
||||
<div className="mt-auto border-t border-[var(--border)] p-4 relative" ref={menuRef}>
|
||||
{tailscaleIssue && !collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setModal('platforms')
|
||||
}}
|
||||
className={`mb-3 w-full rounded-lg border px-3 py-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-md transition-colors ${
|
||||
tailscaleIssue.state === 'checking'
|
||||
? 'border-sky-300/18 bg-sky-300/[0.07] hover:border-sky-300/28 hover:bg-sky-300/[0.1]'
|
||||
: 'border-amber-300/18 bg-amber-300/[0.07] hover:border-amber-300/28 hover:bg-amber-300/[0.1]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className={`mt-1 size-2.5 shrink-0 rounded-full ${
|
||||
tailscaleIssue.state === 'checking'
|
||||
? 'animate-pulse bg-sky-300 shadow-[0_0_0_3px_rgba(125,211,252,0.14)]'
|
||||
: 'bg-amber-300 shadow-[0_0_0_3px_rgba(251,191,36,0.14)]'
|
||||
}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-[12px] font-bold ${tailscaleIssue.state === 'checking' ? 'text-sky-100/90' : 'text-amber-100/90'}`}>{tailscaleIssue.title}</span>
|
||||
<span className="mt-1 block text-[11px] font-medium leading-4 text-white/48">{tailscaleIssue.message}</span>
|
||||
</span>
|
||||
{tailscaleIssue.state !== 'checking' && onTailscaleRetry && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onTailscaleRetry()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onTailscaleRetry()
|
||||
}}
|
||||
className="mt-0.5 shrink-0 rounded-md border border-white/12 bg-white/[0.06] px-2.5 py-1 text-[11px] font-bold text-white/68 transition-colors hover:bg-white/[0.1] hover:text-white"
|
||||
>
|
||||
재시도
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 드롭업 메뉴 */}
|
||||
{menuOpen && !collapsed && (
|
||||
|
||||
@@ -18,6 +18,7 @@ const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || '
|
||||
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
||||
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
||||
const IS_DEV = import.meta.env.DEV
|
||||
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
|
||||
|
||||
const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
||||
const normalizedAppKey = appKey.trim().toLowerCase()
|
||||
@@ -26,6 +27,19 @@ const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> => {
|
||||
let timer: number | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = window.setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout])
|
||||
} finally {
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
type AppNotice = {
|
||||
id: string
|
||||
date: string
|
||||
@@ -60,6 +74,56 @@ type AuthMeResponse = {
|
||||
user?: AuthSession['user']
|
||||
}
|
||||
|
||||
type TailscaleProvisionResponse = {
|
||||
tailscaleAuthKey?: string
|
||||
tailscaleLoginServer?: string
|
||||
tailscaleHostname?: string
|
||||
}
|
||||
|
||||
function normalizeHeadscaleAccount(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/@/g, '-at-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount: string): boolean {
|
||||
if (!statusAccount) return false
|
||||
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||
}
|
||||
|
||||
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
||||
return Boolean(
|
||||
status?.backendState === 'Unavailable' &&
|
||||
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? ''),
|
||||
)
|
||||
}
|
||||
|
||||
function extractProvisionErrorMessage(payload: unknown): string {
|
||||
if (!payload) return ''
|
||||
if (typeof payload === 'string') return payload
|
||||
if (Array.isArray(payload)) return payload.map(extractProvisionErrorMessage).filter(Boolean).join(', ')
|
||||
if (typeof payload !== 'object') return ''
|
||||
|
||||
const record = payload as Record<string, unknown>
|
||||
const candidates = [record.message, record.details, record.error]
|
||||
for (const candidate of candidates) {
|
||||
const message = extractProvisionErrorMessage(candidate)
|
||||
if (message && message !== '[object Object]' && !/^(bad gateway|internal server error|unauthorized|forbidden)$/i.test(message)) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
|
||||
const [apps, setApps] = useState<LauncherApp[]>([])
|
||||
const [activeApp, setActiveApp] = useState('')
|
||||
@@ -86,9 +150,12 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
const [appActionMenuOpen, setAppActionMenuOpen] = useState(false)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [deletingApps, setDeletingApps] = useState<Record<string, boolean>>({})
|
||||
const [tailscaleIssue, setTailscaleIssue] = useState<{ state?: 'checking' | 'warning'; title: string; message: string } | null>(null)
|
||||
const [tailscaleRetryNonce, setTailscaleRetryNonce] = useState(0)
|
||||
const noticeRef = useRef<HTMLDivElement>(null)
|
||||
const appActionMenuRef = useRef<HTMLDivElement>(null)
|
||||
const initialAppSelectionDoneRef = useRef(false)
|
||||
const tailscaleProvisionUserRef = useRef('')
|
||||
|
||||
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
||||
const hasNotices = appNotices.length > 0
|
||||
@@ -152,6 +219,126 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
return response
|
||||
}, [authSession.accessToken, onLogout, refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
const launcherTailscale = window.launcherTailscale
|
||||
if (!launcherTailscale) return
|
||||
const userEmail = authSession.user.email.trim().toLowerCase()
|
||||
const provisionKey = `${userEmail}:${tailscaleRetryNonce}`
|
||||
if (!userEmail || tailscaleProvisionUserRef.current === provisionKey) return
|
||||
tailscaleProvisionUserRef.current = provisionKey
|
||||
let cancelled = false
|
||||
|
||||
const connectTailscaleInBackground = async (): Promise<void> => {
|
||||
try {
|
||||
setTailscaleIssue({
|
||||
state: 'checking',
|
||||
title: '원격 연결 확인 중',
|
||||
message: '연결 상태를 확인하고 있습니다.',
|
||||
})
|
||||
await withTimeout((async () => {
|
||||
const status = await launcherTailscale.status().catch(() => null)
|
||||
if (cancelled) return
|
||||
if (isTailscaleMissingStatus(status)) {
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 구성 요소 설치 필요',
|
||||
message: '플랫폼 연결을 사용하려면 원격 연결 구성 요소 설치가 필요합니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isSameAccount = Boolean(
|
||||
status &&
|
||||
(
|
||||
isSameTailscaleAccount(status.loginName, userEmail) ||
|
||||
isSameTailscaleAccount(status.userName, userEmail)
|
||||
),
|
||||
)
|
||||
if (status?.connected && isSameAccount) {
|
||||
setTailscaleIssue(null)
|
||||
return
|
||||
}
|
||||
|
||||
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
||||
if (cancelled) return
|
||||
|
||||
const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
|
||||
usageType: 'launcher-client',
|
||||
publicSshKey: sshKey?.publicKey,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as unknown
|
||||
throw new Error(extractProvisionErrorMessage(payload) || 'Tailscale 연결 정보를 가져오지 못했습니다.')
|
||||
}
|
||||
|
||||
const provision = await response.json() as TailscaleProvisionResponse
|
||||
if (!provision.tailscaleAuthKey) {
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 정보 없음',
|
||||
message: '플랫폼 연결 정보를 가져오지 못했습니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await launcherTailscale.login(
|
||||
provision.tailscaleAuthKey,
|
||||
provision.tailscaleLoginServer ?? '',
|
||||
provision.tailscaleHostname,
|
||||
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
||||
)
|
||||
if (!result.ok) {
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 확인 필요',
|
||||
message: result.message || '원격 연결에 실패했습니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextStatus = await launcherTailscale.status().catch(() => null)
|
||||
const nextSameAccount = Boolean(
|
||||
nextStatus &&
|
||||
(
|
||||
isSameTailscaleAccount(nextStatus.loginName, userEmail) ||
|
||||
isSameTailscaleAccount(nextStatus.userName, userEmail)
|
||||
),
|
||||
)
|
||||
|
||||
if (nextStatus?.connected && nextSameAccount) {
|
||||
setTailscaleIssue(null)
|
||||
} else {
|
||||
const detail = nextStatus?.error || nextStatus?.health?.[0]
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 확인 필요',
|
||||
message: detail || '현재 로그인 계정으로 원격 연결이 정상 처리되지 않았습니다.',
|
||||
})
|
||||
}
|
||||
})(), TAILSCALE_BACKGROUND_TIMEOUT_MS, '원격 연결 확인 시간이 초과되었습니다.')
|
||||
} catch (error) {
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 확인 필요',
|
||||
message: error instanceof Error ? error.message : '원격 연결에 실패했습니다.',
|
||||
})
|
||||
console.warn('[launcher] background tailscale login failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
void connectTailscaleInBackground()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authSession.user.email, fetchWithAuthRefresh, tailscaleRetryNonce])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -653,6 +840,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
launcherUpdateMessage={launcherUpdateMessage}
|
||||
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
|
||||
authAccessToken={authSession.accessToken}
|
||||
tailscaleIssue={tailscaleIssue}
|
||||
onTailscaleRetry={() => setTailscaleRetryNonce((value) => value + 1)}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
|
||||
@@ -54,6 +54,8 @@ function Login({ onLogin }: LoginProps): React.JSX.Element {
|
||||
const [updateProgress, setUpdateProgress] = useState(0)
|
||||
const [typedCount, setTypedCount] = useState(0)
|
||||
const [loginLoading, setLoginLoading] = useState(false)
|
||||
const [loginEmail, setLoginEmail] = useState('')
|
||||
const [loginNotice, setLoginNotice] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -258,10 +260,19 @@ function Login({ onLogin }: LoginProps): React.JSX.Element {
|
||||
onGoSignup={() => setView('signup')}
|
||||
onGoPasswordReset={() => setView('password-reset')}
|
||||
onLoadingChange={setLoginLoading}
|
||||
initialEmail={loginEmail}
|
||||
noticeMessage={loginNotice}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[420px] flex items-center justify-center">
|
||||
<SignupForm onGoLogin={() => setView('login')} onSignupComplete={onLogin} />
|
||||
<SignupForm
|
||||
onGoLogin={() => setView('login')}
|
||||
onSignupComplete={(email) => {
|
||||
setLoginEmail(email)
|
||||
setLoginNotice('회원가입이 완료되었습니다. 비밀번호를 입력해 로그인해주세요.')
|
||||
setView('login')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[420px] flex items-center justify-center">
|
||||
<PasswordResetForm onGoLogin={() => setView('login')} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||
|
||||
interface LoginFormProps {
|
||||
@@ -7,6 +6,8 @@ interface LoginFormProps {
|
||||
onGoSignup: () => void
|
||||
onGoPasswordReset: () => void
|
||||
onLoadingChange: (v: boolean) => void
|
||||
initialEmail?: string
|
||||
noticeMessage?: string
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
@@ -44,78 +45,12 @@ const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || '
|
||||
const AUTH_API_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100').replace(/\/+$/, '')
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const MIN_LOGIN_DURATION_MS = 2000
|
||||
const GOOGLE_LOGIN_WAIT_TIMEOUT_MS = 30_000
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
const LAST_LOGIN_EMAIL_KEY = 'atnmy.auth.last-login-email'
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
|
||||
type TailscaleProvisionResponse = {
|
||||
installTailscale?: boolean
|
||||
tailscaleAuthKey?: string
|
||||
tailscaleLoginServer?: string
|
||||
tailscaleHostname?: string
|
||||
}
|
||||
|
||||
type PendingTailscaleConfirm = {
|
||||
session: AuthSession
|
||||
email: string
|
||||
password: string
|
||||
keepLogin: boolean
|
||||
message: string
|
||||
reason: 'install-required' | 'connection-failed'
|
||||
}
|
||||
|
||||
class TailscaleInstallRequiredError extends Error {
|
||||
constructor() {
|
||||
super('원격 연결 구성 요소 설치가 필요합니다.')
|
||||
this.name = 'TailscaleInstallRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
||||
return Boolean(
|
||||
status?.backendState === 'Unavailable' &&
|
||||
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function extractProvisionErrorMessage(payload: unknown): string {
|
||||
if (!payload) return ''
|
||||
if (typeof payload === 'string') return payload
|
||||
if (Array.isArray(payload)) return payload.map(extractProvisionErrorMessage).filter(Boolean).join(', ')
|
||||
if (typeof payload !== 'object') return ''
|
||||
|
||||
const record = payload as Record<string, unknown>
|
||||
const candidates = [record.message, record.details, record.error]
|
||||
for (const candidate of candidates) {
|
||||
const message = extractProvisionErrorMessage(candidate)
|
||||
if (message && message !== '[object Object]' && !/^(bad gateway|internal server error|unauthorized|forbidden)$/i.test(message)) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeadscaleAccount(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/@/g, '-at-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount: string): boolean {
|
||||
if (!statusAccount) return false
|
||||
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||
}
|
||||
|
||||
function readLastLoginEmail(): string {
|
||||
try {
|
||||
return window.localStorage.getItem(LAST_LOGIN_EMAIL_KEY) ?? ''
|
||||
@@ -132,75 +67,26 @@ function storeLastLoginEmail(email: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLauncherTailscale(account: string, password: string): Promise<void> {
|
||||
if (!window.launcherTailscale) return
|
||||
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
||||
|
||||
const status = await window.launcherTailscale.status().catch(() => null)
|
||||
const isSameAccount = Boolean(
|
||||
status &&
|
||||
(
|
||||
isSameTailscaleAccount(status.loginName, account) ||
|
||||
isSameTailscaleAccount(status.userName, account)
|
||||
)
|
||||
)
|
||||
if (
|
||||
status?.connected &&
|
||||
isSameAccount
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (isTailscaleMissingStatus(status)) {
|
||||
throw new TailscaleInstallRequiredError()
|
||||
}
|
||||
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
|
||||
usageType: 'launcher-client',
|
||||
publicSshKey: sshKey?.publicKey,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as unknown
|
||||
const message = extractProvisionErrorMessage(payload)
|
||||
throw new Error(message ? `Tailscale 연결 정보를 가져오지 못했습니다: ${message}` : 'Tailscale 연결 정보를 가져오지 못했습니다.')
|
||||
}
|
||||
|
||||
const provision = await response.json() as TailscaleProvisionResponse
|
||||
if (!provision.tailscaleAuthKey) {
|
||||
throw new Error('Tailscale 인증키가 없습니다.')
|
||||
}
|
||||
|
||||
const result = await window.launcherTailscale.login(
|
||||
provision.tailscaleAuthKey,
|
||||
provision.tailscaleLoginServer ?? '',
|
||||
provision.tailscaleHostname,
|
||||
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
||||
)
|
||||
if (!result.ok) {
|
||||
const nextStatus = await window.launcherTailscale.status().catch(() => null)
|
||||
if (nextStatus?.connected) return
|
||||
throw new Error(result.message || 'Tailscale 연결에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }: LoginFormProps): React.JSX.Element {
|
||||
const [email, setEmail] = useState(() => readLastLoginEmail())
|
||||
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, initialEmail, noticeMessage }: LoginFormProps): React.JSX.Element {
|
||||
const [email, setEmail] = useState(() => initialEmail || readLastLoginEmail())
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [keepLogin, setKeepLogin] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
|
||||
const [tailscaleInstalling, setTailscaleInstalling] = useState(false)
|
||||
const googleLoginTimeoutRef = useRef<number | null>(null)
|
||||
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
|
||||
const clearGoogleLoginTimeout = (): void => {
|
||||
if (!googleLoginTimeoutRef.current) return
|
||||
window.clearTimeout(googleLoginTimeoutRef.current)
|
||||
googleLoginTimeoutRef.current = null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialEmail) setEmail(initialEmail)
|
||||
}, [initialEmail])
|
||||
|
||||
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
|
||||
storeLastLoginEmail(loginEmail.trim())
|
||||
if (persistLogin) {
|
||||
@@ -219,7 +105,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.launcherAuth?.onGoogleOAuthCallback(async (payload) => {
|
||||
setPendingTailscaleConfirm(null)
|
||||
clearGoogleLoginTimeout()
|
||||
setError('')
|
||||
|
||||
if (payload.error || !payload.loginToken) {
|
||||
@@ -252,16 +138,26 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
return () => {
|
||||
clearGoogleLoginTimeout()
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [completeLogin, keepLogin, onLoadingChange])
|
||||
|
||||
const handleGoogleLogin = async (): Promise<void> => {
|
||||
clearGoogleLoginTimeout()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
onLoadingChange(true)
|
||||
|
||||
try {
|
||||
await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`)
|
||||
googleLoginTimeoutRef.current = window.setTimeout(() => {
|
||||
googleLoginTimeoutRef.current = null
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
setError('Google 로그인 응답이 없어 대기를 종료했습니다. 브라우저에서 로그인을 취소했다면 다시 시도해주세요.')
|
||||
}, GOOGLE_LOGIN_WAIT_TIMEOUT_MS)
|
||||
} catch {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
@@ -274,7 +170,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
if (emailError) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setPendingTailscaleConfirm(null)
|
||||
onLoadingChange(true)
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
@@ -288,21 +183,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
||||
}
|
||||
const session = await response.json() as AuthSession
|
||||
try {
|
||||
await ensureLauncherTailscale(email, password)
|
||||
} catch (tailscaleError) {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
setPendingTailscaleConfirm({
|
||||
session,
|
||||
email,
|
||||
password,
|
||||
keepLogin,
|
||||
reason: tailscaleError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
|
||||
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
await completeLogin(session, email, keepLogin, startedAt)
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
|
||||
@@ -312,88 +192,17 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
}
|
||||
}
|
||||
|
||||
const continueWithoutTailscale = async (): Promise<void> => {
|
||||
if (!pendingTailscaleConfirm) return
|
||||
const pending = pendingTailscaleConfirm
|
||||
setPendingTailscaleConfirm(null)
|
||||
setLoading(true)
|
||||
setError('')
|
||||
onLoadingChange(true)
|
||||
try {
|
||||
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||
} catch (continueError) {
|
||||
setError(continueError instanceof Error ? continueError.message : '로그인에 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const retryTailscaleAndContinue = async (): Promise<void> => {
|
||||
if (!pendingTailscaleConfirm) return
|
||||
const pending = pendingTailscaleConfirm
|
||||
setTailscaleInstalling(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await ensureLauncherTailscale(pending.email, pending.password)
|
||||
setPendingTailscaleConfirm(null)
|
||||
setLoading(true)
|
||||
onLoadingChange(true)
|
||||
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||
} catch (retryError) {
|
||||
setPendingTailscaleConfirm({
|
||||
...pending,
|
||||
reason: retryError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
|
||||
message: retryError instanceof Error ? retryError.message : 'Tailscale 연결에 실패했습니다.',
|
||||
})
|
||||
} finally {
|
||||
setTailscaleInstalling(false)
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const installTailscaleAndContinue = async (): Promise<void> => {
|
||||
if (!pendingTailscaleConfirm || !window.launcherTailscale?.install) return
|
||||
const pending = pendingTailscaleConfirm
|
||||
setTailscaleInstalling(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const installResult = await window.launcherTailscale.install()
|
||||
if (!installResult.ok) {
|
||||
setPendingTailscaleConfirm({
|
||||
...pending,
|
||||
message: installResult.message || 'Tailscale 설치에 실패했습니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ensureLauncherTailscale(pending.email, pending.password)
|
||||
setPendingTailscaleConfirm(null)
|
||||
setLoading(true)
|
||||
onLoadingChange(true)
|
||||
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||
} catch (installError) {
|
||||
setPendingTailscaleConfirm({
|
||||
...pending,
|
||||
reason: installError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
|
||||
message: installError instanceof Error ? installError.message : 'Tailscale 설치 또는 연결에 실패했습니다.',
|
||||
})
|
||||
} finally {
|
||||
setTailscaleInstalling(false)
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
|
||||
|
||||
<h2 className="text-[20px] font-bold text-[var(--text-primary)] mb-1">로그인</h2>
|
||||
<p className="text-[12px] text-[var(--text-muted)] mb-6">계정에 로그인하여 서비스를 이용하세요.</p>
|
||||
{noticeMessage && (
|
||||
<div className="mb-4 rounded-lg border border-[rgba(59,200,130,0.25)] bg-[rgba(59,200,130,0.08)] px-3 py-2 text-[12px] font-medium leading-relaxed text-[rgba(120,235,175,0.92)]">
|
||||
{noticeMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div>
|
||||
@@ -467,57 +276,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{pendingTailscaleConfirm && createPortal(
|
||||
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/45 px-6 backdrop-blur-[2px]">
|
||||
<section className="w-full max-w-[400px] overflow-hidden rounded-xl border border-white/14 bg-[#121722] shadow-[0_28px_80px_rgba(0,0,0,0.45)]">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<h3 className="text-[15px] font-bold text-white">
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? '원격 연결 구성 요소 설치' : 'Tailscale 연결 실패'}
|
||||
</h3>
|
||||
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
|
||||
{pendingTailscaleConfirm.reason === 'install-required'
|
||||
? 'Autonomy Studio는 플랫폼 연결을 위해 Tailscale을 사용합니다. 설치를 진행하려면 관리자 권한이 필요합니다.'
|
||||
: 'Tailscale은 설치되어 있지만 연결 정보를 가져오거나 서버에 접속하는 과정에서 실패했습니다. 잠시 후 다시 시도할 수 있습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="max-h-[96px] overflow-y-auto rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-medium leading-relaxed text-white/56">
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? 'Tailscale이 설치되어 있지 않습니다.' : pendingTailscaleConfirm.message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continueWithoutTailscale()}
|
||||
className="h-9 cursor-pointer rounded-md border border-white/16 bg-white/6 px-4 text-[12px] font-bold text-white/72 transition hover:bg-white/10 hover:text-white"
|
||||
disabled={tailscaleInstalling}
|
||||
>
|
||||
나중에
|
||||
</button>
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installTailscaleAndContinue()}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-60"
|
||||
disabled={tailscaleInstalling || !window.launcherTailscale?.install}
|
||||
>
|
||||
{tailscaleInstalling ? '설치 중...' : '설치'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void retryTailscaleAndContinue()}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-60"
|
||||
disabled={tailscaleInstalling}
|
||||
>
|
||||
{tailscaleInstalling ? '재시도 중...' : '다시 시도'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { EnvelopeIcon, LockIcon, PersonIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||
import type { AuthSession } from './LoginForm'
|
||||
|
||||
interface SignupFormProps {
|
||||
onGoLogin: () => void
|
||||
onSignupComplete: (session: AuthSession) => void
|
||||
onSignupComplete: (email: string) => void
|
||||
}
|
||||
|
||||
const AUTH_BASE_URL = (
|
||||
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
||||
).replace(/\/+$/, '')
|
||||
const AUTH_API_BASE_URL = AUTH_BASE_URL
|
||||
const LAUNCHER_API_BASE_URL = (
|
||||
import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200'
|
||||
).replace(/\/+$/, '')
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
|
||||
const termLabels: Record<string, string> = {
|
||||
terms: '이용약관',
|
||||
privacy: '개인정보 처리방침',
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = []
|
||||
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)]+)\)|\*\*([^*]+)\*\*)/g
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index))
|
||||
}
|
||||
|
||||
if (match[2] && match[3]) {
|
||||
nodes.push(
|
||||
<a
|
||||
key={`link-${match.index}`}
|
||||
href={match[3]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline"
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
)
|
||||
} else if (match[4]) {
|
||||
nodes.push(
|
||||
<strong key={`strong-${match.index}`} className="font-semibold text-[var(--text-primary)]">
|
||||
{match[4]}
|
||||
</strong>,
|
||||
)
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): ReactNode[] {
|
||||
const blocks: ReactNode[] = []
|
||||
const lines = markdown.split(/\r?\n/)
|
||||
let listItems: ReactNode[][] = []
|
||||
let listType: 'ul' | 'ol' | null = null
|
||||
|
||||
const flushList = (): void => {
|
||||
if (!listType || listItems.length === 0) return
|
||||
const Tag = listType
|
||||
blocks.push(
|
||||
<Tag
|
||||
key={`list-${blocks.length}`}
|
||||
className={`${listType === 'ul' ? 'list-disc' : 'list-decimal'} my-3 space-y-1 pl-5 text-[13px] leading-6 text-[var(--text-secondary)]`}
|
||||
>
|
||||
{listItems.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</Tag>,
|
||||
)
|
||||
listItems = []
|
||||
listType = null
|
||||
}
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
flushList()
|
||||
return
|
||||
}
|
||||
|
||||
if (/^[-*_]{3,}$/.test(trimmed)) {
|
||||
flushList()
|
||||
blocks.push(
|
||||
<hr
|
||||
key={`hr-${blocks.length}`}
|
||||
className="my-5 border-0 border-t border-[rgba(255,255,255,0.12)]"
|
||||
/>,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/)
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/)
|
||||
if (unorderedMatch || orderedMatch) {
|
||||
const nextType = unorderedMatch ? 'ul' : 'ol'
|
||||
if (listType && listType !== nextType) {
|
||||
flushList()
|
||||
}
|
||||
listType = nextType
|
||||
listItems.push(renderInlineMarkdown((unorderedMatch || orderedMatch)?.[1] || ''))
|
||||
return
|
||||
}
|
||||
|
||||
flushList()
|
||||
|
||||
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/)
|
||||
if (heading) {
|
||||
const level = heading[1].length
|
||||
const content = renderInlineMarkdown(heading[2])
|
||||
if (level === 1) {
|
||||
blocks.push(
|
||||
<h1 key={`h1-${blocks.length}`} className="mb-5 mt-1 text-center text-[18px] font-bold text-[var(--text-primary)]">
|
||||
{content}
|
||||
</h1>,
|
||||
)
|
||||
} else if (level === 2) {
|
||||
blocks.push(
|
||||
<h2 key={`h2-${blocks.length}`} className="mb-2 mt-5 text-[15px] font-semibold text-[var(--text-primary)]">
|
||||
{content}
|
||||
</h2>,
|
||||
)
|
||||
} else {
|
||||
blocks.push(
|
||||
<h3 key={`h3-${blocks.length}`} className="mb-2 mt-4 text-[14px] font-semibold text-[var(--text-primary)]">
|
||||
{content}
|
||||
</h3>,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<p key={`p-${blocks.length}`} className="my-2 text-[13px] leading-6 text-[var(--text-secondary)]">
|
||||
{renderInlineMarkdown(trimmed)}
|
||||
</p>,
|
||||
)
|
||||
})
|
||||
|
||||
flushList()
|
||||
|
||||
return blocks.length > 0
|
||||
? blocks
|
||||
: [<p key="empty" className="text-[13px] text-[var(--text-muted)]">등록된 약관 내용이 없습니다.</p>]
|
||||
}
|
||||
|
||||
function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX.Element {
|
||||
const [name, setName] = useState('')
|
||||
@@ -30,6 +174,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
const [promoCode, setPromoCode] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [termModal, setTermModal] = useState<{ key: 'terms' | 'privacy'; title: string; body: string; loading: boolean } | null>(null)
|
||||
const emailError = signupEmail.length > 0 && !EMAIL_PATTERN.test(signupEmail) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
|
||||
|
||||
@@ -125,10 +270,8 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
throw new Error('회원가입에 실패했습니다.')
|
||||
}
|
||||
|
||||
const session = await response.json() as AuthSession
|
||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
onSignupComplete(session)
|
||||
await response.json().catch(() => null)
|
||||
onSignupComplete(normalizedSignupEmail)
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
|
||||
} finally {
|
||||
@@ -136,7 +279,65 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
}
|
||||
}
|
||||
|
||||
const openTermModal = async (key: 'terms' | 'privacy'): Promise<void> => {
|
||||
const title = termLabels[key]
|
||||
setTermModal({ key, title, body: '', loading: true })
|
||||
|
||||
try {
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/terms/${key}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('약관을 불러오지 못했습니다.')
|
||||
}
|
||||
|
||||
const payload = await response.json() as { term?: { body?: string } }
|
||||
setTermModal({ key, title, body: payload.term?.body || '등록된 약관 내용이 없습니다.', loading: false })
|
||||
} catch (termError) {
|
||||
setTermModal({
|
||||
key,
|
||||
title,
|
||||
body: termError instanceof Error ? termError.message : '약관을 불러오지 못했습니다.',
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleSignup = async (): Promise<void> => {
|
||||
setError('')
|
||||
try {
|
||||
await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`)
|
||||
} catch {
|
||||
setError('Google 로그인 창을 열 수 없습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const termModalElement = termModal ? createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] px-4 backdrop-blur-[10px]">
|
||||
<div className="w-full max-w-[820px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.72)] shadow-[0_28px_90px_rgba(0,0,0,0.58),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between border-b border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.035)] px-5 py-4">
|
||||
<h3 className="text-left text-[15px] font-semibold text-[var(--text-primary)]">약관 조회</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTermModal(null)}
|
||||
className="flex size-8 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-white/10 hover:text-[var(--text-primary)]"
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[58vh] overflow-y-auto px-5 py-4">
|
||||
{termModal.loading ? (
|
||||
<p className="text-[13px] text-[var(--text-muted)]">불러오는 중...</p>
|
||||
) : (
|
||||
<div className="break-words">{renderMarkdown(termModal.body)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -284,7 +485,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
이용약관에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||
<button type="button" onClick={() => void openTermModal('terms')} className="text-[11px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors ml-2 shrink-0 cursor-pointer">보기</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
@@ -293,7 +494,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
개인정보 처리방침에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||
<button type="button" onClick={() => void openTermModal('privacy')} className="text-[11px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors ml-2 shrink-0 cursor-pointer">보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -309,7 +510,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="login-google-btn w-full mb-5">
|
||||
<button type="button" onClick={handleGoogleSignup} className="login-google-btn w-full mb-5">
|
||||
<GoogleIcon />
|
||||
Google로 회원가입
|
||||
</button>
|
||||
@@ -321,6 +522,8 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{termModalElement}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user