diff --git a/package.json b/package.json index 076cf4f..2a632d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autonomy-studio-launcher", - "version": "1.0.24", + "version": "1.0.25", "description": "Autonomy Studio v2 Frontend", "main": "./out/main/index.js", "author": { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f2f480b..7ed0895 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 - ? - : + return ( + <> + {authSession + ? + : } + {sessionExpiredOpen && createPortal( +
+
+
+

로그인이 필요합니다

+
+
+ 로그인 세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요. +
+
+ +
+
+
, + document.body, + )} + + ) } export default App diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index ef5c903..164ccf7 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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(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' && ( - setModal(null)} /> + 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([]) 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 => { setLoading(true) @@ -483,6 +490,39 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS } }, [loadPlatforms]) + useEffect(() => { + let cancelled = false + + const loadRegistrationCode = async (): Promise => { + 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 => { 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 => { + 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 => { + 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 ( void }): React.JS
+
+
+
+

플랫폼 등록

+

에이전트 설치 화면에서 현재 계정 이메일과 등록 키를 입력하세요.

+
+ +
+ {registrationCode && ( +
+
+ + {registrationCode} + + {registrationExpiresAt &&

{formatRemainingTime(registrationExpiresAt, registrationNow)}

} +
+ +
+ )} + {authorizationError &&

{authorizationError}

} +
{tailscaleMissing ? (
@@ -585,7 +709,7 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
) : !selectedPlatform ? ( -
플랫폼을 선택하세요.
+
플랫폼을 선택하세요.
) : ( <>
@@ -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 } 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 ( -