From 0b4d8d2cbbd34f81eeace63ea249015b4aa303f0 Mon Sep 17 00:00:00 2001 From: chyoo Date: Fri, 10 Jul 2026 15:09:06 +0900 Subject: [PATCH] improve launcher auth and remote connection feedback --- src/renderer/src/components/Sidebar.tsx | 53 +++- src/renderer/src/pages/Dashboard.tsx | 189 +++++++++++++++ src/renderer/src/pages/Login.tsx | 13 +- src/renderer/src/pages/LoginForm.tsx | 310 +++--------------------- src/renderer/src/pages/SignupForm.tsx | 225 ++++++++++++++++- 5 files changed, 501 insertions(+), 289 deletions(-) diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index e964a85..ef5c903 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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(null) const [avatarVersion, setAvatarVersion] = useState(() => Date.now()) @@ -236,6 +242,51 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL {/* 유저 프로필 푸터 */}
+ {tailscaleIssue && !collapsed && ( + + )} {/* 드롭업 메뉴 */} {menuOpen && !collapsed && ( diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index dfb452e..71c7489 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -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 (promise: Promise, timeoutMs: number, message: string): Promise => { + let timer: number | undefined + const timeout = new Promise((_, 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['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 + 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([]) 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>({}) + const [tailscaleIssue, setTailscaleIssue] = useState<{ state?: 'checking' | 'warning'; title: string; message: string } | null>(null) + const [tailscaleRetryNonce, setTailscaleRetryNonce] = useState(0) const noticeRef = useRef(null) const appActionMenuRef = useRef(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 => { + 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)} />
diff --git a/src/renderer/src/pages/Login.tsx b/src/renderer/src/pages/Login.tsx index 35eb7ac..d8aa019 100644 --- a/src/renderer/src/pages/Login.tsx +++ b/src/renderer/src/pages/Login.tsx @@ -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} />
- setView('login')} onSignupComplete={onLogin} /> + setView('login')} + onSignupComplete={(email) => { + setLoginEmail(email) + setLoginNotice('회원가입이 완료되었습니다. 비밀번호를 입력해 로그인해주세요.') + setView('login') + }} + />
setView('login')} /> diff --git a/src/renderer/src/pages/LoginForm.tsx b/src/renderer/src/pages/LoginForm.tsx index efc7dd5..3420235 100644 --- a/src/renderer/src/pages/LoginForm.tsx +++ b/src/renderer/src/pages/LoginForm.tsx @@ -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 => 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['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 - 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 { - 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(null) - const [tailscaleInstalling, setTailscaleInstalling] = useState(false) + const googleLoginTimeoutRef = useRef(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 => { 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 => { + 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 => { - 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 => { - 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 => { - 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 ( <>

로그인

계정에 로그인하여 서비스를 이용하세요.

+ {noticeMessage && ( +
+ {noticeMessage} +
+ )}
@@ -467,57 +276,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:

- {pendingTailscaleConfirm && createPortal( -
-
-
-

- {pendingTailscaleConfirm.reason === 'install-required' ? '원격 연결 구성 요소 설치' : 'Tailscale 연결 실패'} -

-

- {pendingTailscaleConfirm.reason === 'install-required' - ? 'Autonomy Studio는 플랫폼 연결을 위해 Tailscale을 사용합니다. 설치를 진행하려면 관리자 권한이 필요합니다.' - : 'Tailscale은 설치되어 있지만 연결 정보를 가져오거나 서버에 접속하는 과정에서 실패했습니다. 잠시 후 다시 시도할 수 있습니다.'} -

-
-
-
- {pendingTailscaleConfirm.reason === 'install-required' ? 'Tailscale이 설치되어 있지 않습니다.' : pendingTailscaleConfirm.message} -
-
-
- - {pendingTailscaleConfirm.reason === 'install-required' ? ( - - ) : ( - - )} -
-
-
, - document.body - )} ) } diff --git a/src/renderer/src/pages/SignupForm.tsx b/src/renderer/src/pages/SignupForm.tsx index a64e7ae..e59137f 100644 --- a/src/renderer/src/pages/SignupForm.tsx +++ b/src/renderer/src/pages/SignupForm.tsx @@ -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 = { + 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( + + {match[2]} + , + ) + } else if (match[4]) { + nodes.push( + + {match[4]} + , + ) + } + + 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( + + {listItems.map((item, index) => ( +
  • {item}
  • + ))} +
    , + ) + listItems = [] + listType = null + } + + lines.forEach((line) => { + const trimmed = line.trim() + if (!trimmed) { + flushList() + return + } + + if (/^[-*_]{3,}$/.test(trimmed)) { + flushList() + blocks.push( +
    , + ) + 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( +

    + {content} +

    , + ) + } else if (level === 2) { + blocks.push( +

    + {content} +

    , + ) + } else { + blocks.push( +

    + {content} +

    , + ) + } + return + } + + blocks.push( +

    + {renderInlineMarkdown(trimmed)} +

    , + ) + }) + + flushList() + + return blocks.length > 0 + ? blocks + : [

    등록된 약관 내용이 없습니다.

    ] +} 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 => { + 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 => { + setError('') + try { + await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`) + } catch { + setError('Google 로그인 창을 열 수 없습니다.') + } + } + + const termModalElement = termModal ? createPortal( +
    +
    +
    +

    약관 조회

    + +
    +
    + {termModal.loading ? ( +

    불러오는 중...

    + ) : ( +
    {renderMarkdown(termModal.body)}
    + )} +
    +
    +
    , + document.body + ) : null + return ( + <>
    @@ -284,7 +485,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX 이용약관에 동의합니다. (필수) - +
    - +
    @@ -309,7 +510,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
    - @@ -321,6 +522,8 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX

    + {termModalElement} + ) }