merge develop into release

This commit is contained in:
2026-07-13 16:51:26 +09:00
5 changed files with 218 additions and 31 deletions
+58 -12
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AppWindowShell from './pages/AppWindowShell'
@@ -16,6 +17,7 @@ function App(): React.JSX.Element {
return null
}
})
const [sessionExpiredOpen, setSessionExpiredOpen] = useState(false)
const meta = window.appWindowMeta
if (meta?.isAppWindow) {
@@ -23,13 +25,20 @@ function App(): React.JSX.Element {
}
const handleSessionUpdate = useCallback((session: AuthSession): void => {
setAuthSession(session)
if (window.localStorage.getItem(AUTH_SESSION_KEY)) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) {
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
const nextSession: AuthSession = {
...session,
user: {
...session.user,
loginMethod: session.user.loginMethod ?? authSession?.user.loginMethod,
},
}
}, [])
setAuthSession(nextSession)
if (window.localStorage.getItem(AUTH_SESSION_KEY)) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(nextSession))
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) {
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(nextSession))
}
}, [authSession?.user.loginMethod])
const requestLogout = useCallback((accessToken?: string): void => {
void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, {
@@ -48,6 +57,17 @@ function App(): React.JSX.Element {
setAuthSession(null)
}, [authSession?.accessToken, requestLogout])
const handleSessionExpired = useCallback((): void => {
setSessionExpiredOpen(true)
}, [])
const confirmSessionExpired = useCallback((): void => {
window.localStorage.removeItem(AUTH_SESSION_KEY)
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
setSessionExpiredOpen(false)
setAuthSession(null)
}, [])
useEffect(() => {
if (!authSession?.accessToken) return
@@ -79,20 +99,46 @@ function App(): React.JSX.Element {
if (session?.accessToken) {
handleSessionUpdate(session)
} else {
handleLogout()
handleSessionExpired()
}
})
.catch(() => {
handleLogout()
handleSessionExpired()
})
}, refreshDelayMs)
return () => window.clearTimeout(timer)
}, [authSession, handleLogout, handleSessionUpdate])
}, [authSession, handleSessionExpired, handleSessionUpdate])
return authSession
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} />
: <Login onLogin={setAuthSession} />
return (
<>
{authSession
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} onSessionExpired={handleSessionExpired} />
: <Login onLogin={setAuthSession} />}
{sessionExpiredOpen && createPortal(
<div className="fixed inset-0 z-[20000] flex items-center justify-center bg-[rgba(2,6,18,0.5)] px-4 backdrop-blur-[6px]">
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.86)] 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="border-b border-[rgba(255,255,255,0.1)] px-5 py-4">
<h2 className="text-[16px] font-bold text-[var(--text-primary)]">로그인이 필요합니다</h2>
</div>
<div className="px-5 py-5 text-[13px] leading-6 text-[var(--text-muted)]">
로그인 세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.
</div>
<div className="flex justify-end border-t border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.025)] px-5 py-3">
<button
type="button"
onClick={confirmSessionExpired}
className="rounded-md bg-[var(--accent)] px-4 py-2 text-[13px] font-semibold text-white transition-colors hover:bg-[#6aa0ff]"
>
확인
</button>
</div>
</section>
</div>,
document.body,
)}
</>
)
}
export default App
+143 -8
View File
@@ -72,6 +72,7 @@ interface SidebarProps {
launcherUpdateMessage: string
onLauncherUpdate: () => void
authAccessToken: string
authLoginMethod?: 'GOOGLE' | 'PASSWORD'
tailscaleIssue?: {
state?: 'checking' | 'warning'
title: string
@@ -105,7 +106,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, tailscaleIssue, onTailscaleRetry }: SidebarProps): React.JSX.Element {
function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastLoginAt, primaryAffiliation, currentLicense, activeApp, onAppChange, onLogout, collapsed, onToggleCollapse, launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, authAccessToken, authLoginMethod, tailscaleIssue, onTailscaleRetry }: SidebarProps): React.JSX.Element {
const [menuOpen, setMenuOpen] = useState(false)
const [modal, setModal] = useState<UserModal>(null)
const [avatarVersion, setAvatarVersion] = useState(() => Date.now())
@@ -396,6 +397,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
primaryAffiliation={primaryAffiliation}
currentLicense={currentLicense}
accessToken={authAccessToken}
loginMethod={authLoginMethod}
avatarObjectUrl={avatarObjectUrl}
avatarMissing={avatarMissing}
onAvatarMissingChange={setAvatarMissing}
@@ -408,7 +410,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
)}
{modal === 'platforms' && (
<PlatformManagementModal onClose={() => setModal(null)} />
<PlatformManagementModal accessToken={authAccessToken} onClose={() => setModal(null)} />
)}
{modal === 'settings' && (
@@ -424,13 +426,18 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
)
}
function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JSX.Element {
function PlatformManagementModal({ accessToken, onClose }: { accessToken: string; onClose: () => void }): React.JSX.Element {
const [loading, setLoading] = useState(true)
const [installing, setInstalling] = useState(false)
const [tailscaleMissing, setTailscaleMissing] = useState(false)
const [error, setError] = useState('')
const [platforms, setPlatforms] = useState<VpnPlatformPeer[]>([])
const [selectedId, setSelectedId] = useState('')
const [registrationCode, setRegistrationCode] = useState('')
const [registrationExpiresAt, setRegistrationExpiresAt] = useState('')
const [registrationNow, setRegistrationNow] = useState(() => Date.now())
const [registrationLoading, setRegistrationLoading] = useState(false)
const [authorizationError, setAuthorizationError] = useState('')
const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
setLoading(true)
@@ -483,6 +490,39 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
}
}, [loadPlatforms])
useEffect(() => {
let cancelled = false
const loadRegistrationCode = async (): Promise<void> => {
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision/device-authorizations/current`, {
headers: { Authorization: `Bearer ${accessToken}` },
credentials: 'include',
})
if (!response.ok) return
const payload = await response.json().catch(() => null) as { code?: string; expiresAt?: string } | null
if (!cancelled && payload?.code) {
setRegistrationCode(payload.code)
setRegistrationExpiresAt(payload.expiresAt || '')
}
} catch {
// A registration code is optional; leave the modal usable when lookup fails.
}
}
void loadRegistrationCode()
return () => {
cancelled = true
}
}, [accessToken])
useEffect(() => {
if (!registrationExpiresAt) return
setRegistrationNow(Date.now())
const timer = window.setInterval(() => setRegistrationNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [registrationExpiresAt])
const installTailscale = async (): Promise<void> => {
if (!window.launcherTailscale?.install) return
setInstalling(true)
@@ -501,6 +541,55 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
const selectedPlatform = platforms.find((platform) => platform.id === selectedId) ?? platforms[0]
const createAgentRegistrationCode = async (): Promise<void> => {
if (registrationLoading) return
setRegistrationLoading(true)
setAuthorizationError('')
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision/device-authorizations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
credentials: 'include',
})
const payload = await response.json().catch(() => null) as { code?: string; expiresAt?: string; message?: string } | null
if (!response.ok) {
throw new Error(payload?.message || '등록 키를 만들지 못했습니다.')
}
if (!payload?.code) throw new Error('등록 키 응답이 올바르지 않습니다.')
setRegistrationCode(payload.code)
setRegistrationExpiresAt(payload.expiresAt || '')
} catch (createError) {
setAuthorizationError(createError instanceof Error ? createError.message : '등록 키를 만들지 못했습니다.')
} finally {
setRegistrationLoading(false)
}
}
const cancelAgentRegistrationCode = async (): Promise<void> => {
if (!registrationCode || registrationLoading) return
setRegistrationLoading(true)
setAuthorizationError('')
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision/device-authorizations/current/cancel`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
credentials: 'include',
})
if (!response.ok) throw new Error('등록 키를 취소하지 못했습니다.')
setRegistrationCode('')
setRegistrationExpiresAt('')
} catch (cancelError) {
setAuthorizationError(cancelError instanceof Error ? cancelError.message : '등록 키를 취소하지 못했습니다.')
} finally {
setRegistrationLoading(false)
}
}
return (
<ModalShell
title="플랫폼 관리"
@@ -569,6 +658,41 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
</aside>
<section className="min-h-0 overflow-y-auto px-6 py-5">
<section className="mb-5 rounded-md border border-[rgba(129,199,255,0.18)] bg-[rgba(77,151,255,0.06)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-[13px] font-bold text-white/86">플랫폼 등록</p>
<p className="mt-1 text-[11px] font-medium leading-5 text-white/48">에이전트 설치 화면에서 현재 계정 이메일과 등록 키를 입력하세요.</p>
</div>
<button
type="button"
onClick={() => void createAgentRegistrationCode()}
disabled={registrationLoading}
className="h-8 shrink-0 cursor-pointer rounded-md bg-[#5588ff] px-3.5 text-[12px] font-bold text-white transition-colors hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-50"
>
키 발급
</button>
</div>
{registrationCode && (
<div className="mt-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="rounded-md border border-[#78a7ff]/35 bg-[#5588ff]/10 px-3 py-2 font-mono text-[16px] font-bold tracking-[0.18em] text-[#c5d7ff]">
{registrationCode}
</span>
{registrationExpiresAt && <p className="shrink-0 text-[11px] font-medium leading-5 text-white/42">{formatRemainingTime(registrationExpiresAt, registrationNow)}</p>}
</div>
<button
type="button"
onClick={() => void cancelAgentRegistrationCode()}
disabled={registrationLoading}
className="h-8 cursor-pointer rounded-md border border-white/14 px-3 text-[11px] font-bold text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-50"
>
취소
</button>
</div>
)}
{authorizationError && <p className="mt-3 text-[11px] font-medium leading-5 text-[#fca5a5]">{authorizationError}</p>}
</section>
{tailscaleMissing ? (
<div className="grid h-full place-items-center px-8 text-center">
<div>
@@ -585,7 +709,7 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
</div>
</div>
) : !selectedPlatform ? (
<div className="grid h-full place-items-center text-[13px] font-semibold text-white/45">플랫폼을 선택하세요.</div>
<div className="grid place-items-center py-8 text-[13px] font-semibold text-white/45">플랫폼을 선택하세요.</div>
) : (
<>
<div className="mb-5 flex items-start justify-between gap-4">
@@ -661,7 +785,7 @@ function ModalShell({ title, icon, children, footer, panelClassName = 'max-h-[82
)
}
function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, primaryAffiliation, currentLicense, accessToken, avatarObjectUrl, avatarMissing, onAvatarMissingChange, onAvatarUpdated, onClose }: {
function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, primaryAffiliation, currentLicense, accessToken, loginMethod, avatarObjectUrl, avatarMissing, onAvatarMissingChange, onAvatarUpdated, onClose }: {
initial: string
userName: string
userEmail: string
@@ -671,6 +795,7 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
primaryAffiliation?: SidebarProps['primaryAffiliation']
currentLicense?: SidebarProps['currentLicense']
accessToken: string
loginMethod?: 'GOOGLE' | 'PASSWORD'
avatarObjectUrl: string | null
avatarMissing: boolean
onAvatarMissingChange: (missing: boolean) => void
@@ -698,6 +823,7 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
const licensePeriod = currentLicense
? `${formatDate(currentLicense.startsAt)} ~ ${currentLicense.expiresAt ? formatDate(currentLicense.expiresAt) : '무기한'}`
: '-'
const isGoogleLogin = loginMethod === 'GOOGLE'
const resetPasswordForm = (): void => {
setCurrentPassword('')
@@ -916,8 +1042,10 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
<SecurityAction
icon={<KeyRound size={15} />}
title="비밀번호 변경"
description="현재 비밀번호 확인 후 새 비밀번호 설정."
description={isGoogleLogin ? 'Google 로그인 계정은 비밀번호를 변경할 수 없습니다.' : '현재 비밀번호 확인 후 새 비밀번호 설정.'}
disabled={isGoogleLogin}
onClick={() => {
if (isGoogleLogin) return
resetPasswordForm()
setPasswordMode(true)
}}
@@ -1116,9 +1244,9 @@ function ProfileRow({ label, value }: { label: string; value: string }): React.J
)
}
function SecurityAction({ icon, title, description, status, onClick }: { icon: React.ReactNode; title: string; description: string; status?: string; onClick?: () => void }): React.JSX.Element {
function SecurityAction({ icon, title, description, status, disabled = false, onClick }: { icon: React.ReactNode; title: string; description: string; status?: string; disabled?: boolean; onClick?: () => void }): React.JSX.Element {
return (
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 border-t border-[rgba(255,255,255,0.07)] py-3 text-left cursor-pointer hover:bg-white/[0.03]">
<button type="button" onClick={onClick} disabled={disabled} className={`flex w-full items-center gap-3 border-t border-[rgba(255,255,255,0.07)] py-3 text-left ${disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer hover:bg-white/[0.03]'}`}>
<span className="text-white/55">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-semibold text-[var(--text-primary)]">{title}</span>
@@ -1197,6 +1325,13 @@ function formatOptionalDateTime(value?: string): string {
return formatDateTime(value)
}
function formatRemainingTime(expiresAt: string, now: number): string {
const remainingSeconds = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000))
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
return `${minutes}분 ${String(seconds).padStart(2, '0')}초`
}
async function resizeImageToWebp(file: File, size: number): Promise<Blob> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
+11 -7
View File
@@ -12,6 +12,7 @@ interface DashboardProps {
authSession: AuthSession
onSessionUpdate: (session: AuthSession) => void
onLogout: () => void
onSessionExpired: () => void
}
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
@@ -124,7 +125,7 @@ function extractProvisionErrorMessage(payload: unknown): string {
}
}
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: DashboardProps): React.JSX.Element {
const [apps, setApps] = useState<LauncherApp[]>([])
const [activeApp, setActiveApp] = useState('')
const [appNotices, setAppNotices] = useState<AppNotice[]>([])
@@ -211,13 +212,13 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
const refreshed = await refreshSession()
if (!refreshed) {
onLogout()
onSessionExpired()
return response
}
response = await request(refreshed.accessToken)
return response
}, [authSession.accessToken, onLogout, refreshSession])
}, [authSession.accessToken, onSessionExpired, refreshSession])
useEffect(() => {
const launcherTailscale = window.launcherTailscale
@@ -373,7 +374,10 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
if (!cancelled && payload?.user) {
const nextSession = {
...session,
user: payload.user,
user: {
...payload.user,
loginMethod: payload.user.loginMethod ?? authSession.user.loginMethod,
},
}
if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) {
return
@@ -727,8 +731,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
onSessionUpdate(nextSession)
response = await requestHandoff(nextSession.accessToken)
} else {
alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
onLogout()
onSessionExpired()
return null
}
}
@@ -742,7 +745,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
const handoff = await response.json() as { handoffToken: string }
return handoff.handoffToken
}, [authSession.accessToken, onLogout, onSessionUpdate])
}, [authSession.accessToken, onSessionExpired, onSessionUpdate])
const handleLaunch = useCallback(async () => {
if (!appConfig || !canLaunch || launchingApp) return
@@ -840,6 +843,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
launcherUpdateMessage={launcherUpdateMessage}
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
authAccessToken={authSession.accessToken}
authLoginMethod={authSession.user.loginMethod}
tailscaleIssue={tailscaleIssue}
onTailscaleRetry={() => setTailscaleRetryNonce((value) => value + 1)}
/>
+5 -3
View File
@@ -20,6 +20,7 @@ export interface AuthSession {
email: string
name: string | null
role: string
loginMethod?: 'GOOGLE' | 'PASSWORD'
roles?: string[]
createdAt?: string
lastLoginAt?: string | null
@@ -87,7 +88,8 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
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, loginMethod: 'GOOGLE' | 'PASSWORD'): Promise<void> => {
session.user.loginMethod = loginMethod
storeLastLoginEmail(loginEmail.trim())
if (persistLogin) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
@@ -129,7 +131,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
if (!response.ok) throw new Error('Google 로그인 세션을 확인할 수 없습니다.')
const session = await response.json() as AuthSession
await completeLogin(session, session.user.email, keepLogin, startedAt)
await completeLogin(session, session.user.email, keepLogin, startedAt, 'GOOGLE')
} catch (oauthError) {
setError(oauthError instanceof Error ? oauthError.message : 'Google 로그인에 실패했습니다.')
} finally {
@@ -183,7 +185,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
throw new Error('이메일 또는 비밀번호를 확인하세요.')
}
const session = await response.json() as AuthSession
await completeLogin(session, email, keepLogin, startedAt)
await completeLogin(session, email, keepLogin, startedAt, 'PASSWORD')
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
} finally {