merge develop into release
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autonomy-studio-launcher",
|
"name": "autonomy-studio-launcher",
|
||||||
"version": "1.0.16",
|
"version": "1.0.23",
|
||||||
"description": "Autonomy Studio v2 Frontend",
|
"description": "Autonomy Studio v2 Frontend",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ interface SidebarProps {
|
|||||||
launcherUpdateMessage: string
|
launcherUpdateMessage: string
|
||||||
onLauncherUpdate: () => void
|
onLauncherUpdate: () => void
|
||||||
authAccessToken: string
|
authAccessToken: string
|
||||||
|
tailscaleIssue?: {
|
||||||
|
state?: 'checking' | 'warning'
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
} | null
|
||||||
|
onTailscaleRetry?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserModal = 'profile' | 'settings' | 'platforms' | null
|
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 [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [modal, setModal] = useState<UserModal>(null)
|
const [modal, setModal] = useState<UserModal>(null)
|
||||||
const [avatarVersion, setAvatarVersion] = useState(() => Date.now())
|
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}>
|
<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 && (
|
{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 NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
||||||
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
||||||
const IS_DEV = import.meta.env.DEV
|
const IS_DEV = import.meta.env.DEV
|
||||||
|
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
||||||
const normalizedAppKey = appKey.trim().toLowerCase()
|
const normalizedAppKey = appKey.trim().toLowerCase()
|
||||||
@@ -26,6 +27,19 @@ const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
|||||||
return 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 = {
|
type AppNotice = {
|
||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
@@ -60,6 +74,56 @@ type AuthMeResponse = {
|
|||||||
user?: AuthSession['user']
|
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 {
|
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
|
||||||
const [apps, setApps] = useState<LauncherApp[]>([])
|
const [apps, setApps] = useState<LauncherApp[]>([])
|
||||||
const [activeApp, setActiveApp] = useState('')
|
const [activeApp, setActiveApp] = useState('')
|
||||||
@@ -86,9 +150,12 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
|||||||
const [appActionMenuOpen, setAppActionMenuOpen] = useState(false)
|
const [appActionMenuOpen, setAppActionMenuOpen] = useState(false)
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||||
const [deletingApps, setDeletingApps] = useState<Record<string, boolean>>({})
|
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 noticeRef = useRef<HTMLDivElement>(null)
|
||||||
const appActionMenuRef = useRef<HTMLDivElement>(null)
|
const appActionMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const initialAppSelectionDoneRef = useRef(false)
|
const initialAppSelectionDoneRef = useRef(false)
|
||||||
|
const tailscaleProvisionUserRef = useRef('')
|
||||||
|
|
||||||
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
||||||
const hasNotices = appNotices.length > 0
|
const hasNotices = appNotices.length > 0
|
||||||
@@ -152,6 +219,126 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
|||||||
return response
|
return response
|
||||||
}, [authSession.accessToken, onLogout, refreshSession])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
@@ -653,6 +840,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
|||||||
launcherUpdateMessage={launcherUpdateMessage}
|
launcherUpdateMessage={launcherUpdateMessage}
|
||||||
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
|
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
|
||||||
authAccessToken={authSession.accessToken}
|
authAccessToken={authSession.accessToken}
|
||||||
|
tailscaleIssue={tailscaleIssue}
|
||||||
|
onTailscaleRetry={() => setTailscaleRetryNonce((value) => value + 1)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ function Login({ onLogin }: LoginProps): React.JSX.Element {
|
|||||||
const [updateProgress, setUpdateProgress] = useState(0)
|
const [updateProgress, setUpdateProgress] = useState(0)
|
||||||
const [typedCount, setTypedCount] = useState(0)
|
const [typedCount, setTypedCount] = useState(0)
|
||||||
const [loginLoading, setLoginLoading] = useState(false)
|
const [loginLoading, setLoginLoading] = useState(false)
|
||||||
|
const [loginEmail, setLoginEmail] = useState('')
|
||||||
|
const [loginNotice, setLoginNotice] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -258,10 +260,19 @@ function Login({ onLogin }: LoginProps): React.JSX.Element {
|
|||||||
onGoSignup={() => setView('signup')}
|
onGoSignup={() => setView('signup')}
|
||||||
onGoPasswordReset={() => setView('password-reset')}
|
onGoPasswordReset={() => setView('password-reset')}
|
||||||
onLoadingChange={setLoginLoading}
|
onLoadingChange={setLoginLoading}
|
||||||
|
initialEmail={loginEmail}
|
||||||
|
noticeMessage={loginNotice}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[420px] flex items-center justify-center">
|
<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>
|
||||||
<div className="w-[420px] flex items-center justify-center">
|
<div className="w-[420px] flex items-center justify-center">
|
||||||
<PasswordResetForm onGoLogin={() => setView('login')} />
|
<PasswordResetForm onGoLogin={() => setView('login')} />
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
@@ -7,6 +6,8 @@ interface LoginFormProps {
|
|||||||
onGoSignup: () => void
|
onGoSignup: () => void
|
||||||
onGoPasswordReset: () => void
|
onGoPasswordReset: () => void
|
||||||
onLoadingChange: (v: boolean) => void
|
onLoadingChange: (v: boolean) => void
|
||||||
|
initialEmail?: string
|
||||||
|
noticeMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSession {
|
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 AUTH_API_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100').replace(/\/+$/, '')
|
||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
const MIN_LOGIN_DURATION_MS = 2000
|
const MIN_LOGIN_DURATION_MS = 2000
|
||||||
|
const GOOGLE_LOGIN_WAIT_TIMEOUT_MS = 30_000
|
||||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||||
const LAST_LOGIN_EMAIL_KEY = 'atnmy.auth.last-login-email'
|
const LAST_LOGIN_EMAIL_KEY = 'atnmy.auth.last-login-email'
|
||||||
|
|
||||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms))
|
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 {
|
function readLastLoginEmail(): string {
|
||||||
try {
|
try {
|
||||||
return window.localStorage.getItem(LAST_LOGIN_EMAIL_KEY) ?? ''
|
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> {
|
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, initialEmail, noticeMessage }: LoginFormProps): React.JSX.Element {
|
||||||
if (!window.launcherTailscale) return
|
const [email, setEmail] = useState(() => initialEmail || readLastLoginEmail())
|
||||||
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())
|
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [keepLogin, setKeepLogin] = useState(false)
|
const [keepLogin, setKeepLogin] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
|
const googleLoginTimeoutRef = useRef<number | null>(null)
|
||||||
const [tailscaleInstalling, setTailscaleInstalling] = useState(false)
|
|
||||||
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
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> => {
|
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
|
||||||
storeLastLoginEmail(loginEmail.trim())
|
storeLastLoginEmail(loginEmail.trim())
|
||||||
if (persistLogin) {
|
if (persistLogin) {
|
||||||
@@ -219,7 +105,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = window.launcherAuth?.onGoogleOAuthCallback(async (payload) => {
|
const unsubscribe = window.launcherAuth?.onGoogleOAuthCallback(async (payload) => {
|
||||||
setPendingTailscaleConfirm(null)
|
clearGoogleLoginTimeout()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (payload.error || !payload.loginToken) {
|
if (payload.error || !payload.loginToken) {
|
||||||
@@ -252,16 +138,26 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => unsubscribe?.()
|
return () => {
|
||||||
|
clearGoogleLoginTimeout()
|
||||||
|
unsubscribe?.()
|
||||||
|
}
|
||||||
}, [completeLogin, keepLogin, onLoadingChange])
|
}, [completeLogin, keepLogin, onLoadingChange])
|
||||||
|
|
||||||
const handleGoogleLogin = async (): Promise<void> => {
|
const handleGoogleLogin = async (): Promise<void> => {
|
||||||
|
clearGoogleLoginTimeout()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
onLoadingChange(true)
|
onLoadingChange(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`)
|
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 {
|
} catch {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
onLoadingChange(false)
|
onLoadingChange(false)
|
||||||
@@ -274,7 +170,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
|||||||
if (emailError) return
|
if (emailError) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
setPendingTailscaleConfirm(null)
|
|
||||||
onLoadingChange(true)
|
onLoadingChange(true)
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
try {
|
try {
|
||||||
@@ -288,21 +183,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
|||||||
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
||||||
}
|
}
|
||||||
const session = await response.json() as AuthSession
|
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)
|
await completeLogin(session, email, keepLogin, startedAt)
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
|
<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>
|
<h2 className="text-[20px] font-bold text-[var(--text-primary)] mb-1">로그인</h2>
|
||||||
<p className="text-[12px] text-[var(--text-muted)] mb-6">계정에 로그인하여 서비스를 이용하세요.</p>
|
<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 className="flex flex-col gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -467,57 +276,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
|||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</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 { EnvelopeIcon, LockIcon, PersonIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||||
import type { AuthSession } from './LoginForm'
|
|
||||||
|
|
||||||
interface SignupFormProps {
|
interface SignupFormProps {
|
||||||
onGoLogin: () => void
|
onGoLogin: () => void
|
||||||
onSignupComplete: (session: AuthSession) => void
|
onSignupComplete: (email: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTH_BASE_URL = (
|
const AUTH_BASE_URL = (
|
||||||
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
||||||
).replace(/\/+$/, '')
|
).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 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 {
|
function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX.Element {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
@@ -30,6 +174,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
|||||||
const [promoCode, setPromoCode] = useState('')
|
const [promoCode, setPromoCode] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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 emailError = signupEmail.length > 0 && !EMAIL_PATTERN.test(signupEmail) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||||
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
|
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
|
||||||
|
|
||||||
@@ -125,10 +270,8 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
|||||||
throw new Error('회원가입에 실패했습니다.')
|
throw new Error('회원가입에 실패했습니다.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await response.json() as AuthSession
|
await response.json().catch(() => null)
|
||||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
onSignupComplete(normalizedSignupEmail)
|
||||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
|
||||||
onSignupComplete(session)
|
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
|
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
|
<>
|
||||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-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 className="text-[var(--text-muted)]">(필수)</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<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 className="text-[var(--text-muted)]">(필수)</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -309,7 +510,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
|||||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||||
</div>
|
</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 />
|
<GoogleIcon />
|
||||||
Google로 회원가입
|
Google로 회원가입
|
||||||
</button>
|
</button>
|
||||||
@@ -321,6 +522,8 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
|||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
{termModalElement}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user