improve launcher auth and remote connection feedback

This commit is contained in:
2026-07-10 15:09:06 +09:00
parent fc0dfa310a
commit 0b4d8d2cbb
5 changed files with 501 additions and 289 deletions
+52 -1
View File
@@ -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 && (
+189
View File
@@ -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">
+12 -1
View File
@@ -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')} />
+34 -276
View File
@@ -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
)}
</> </>
) )
} }
+214 -11
View File
@@ -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}
</>
) )
} }