modify provisioning..

This commit is contained in:
2026-07-13 16:51:22 +09:00
parent bc740eb49b
commit b1e31f5e3a
5 changed files with 218 additions and 31 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "autonomy-studio-launcher", "name": "autonomy-studio-launcher",
"version": "1.0.24", "version": "1.0.25",
"description": "Autonomy Studio v2 Frontend", "description": "Autonomy Studio v2 Frontend",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+58 -12
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import AppWindowShell from './pages/AppWindowShell' import AppWindowShell from './pages/AppWindowShell'
@@ -16,6 +17,7 @@ function App(): React.JSX.Element {
return null return null
} }
}) })
const [sessionExpiredOpen, setSessionExpiredOpen] = useState(false)
const meta = window.appWindowMeta const meta = window.appWindowMeta
if (meta?.isAppWindow) { if (meta?.isAppWindow) {
@@ -23,13 +25,20 @@ function App(): React.JSX.Element {
} }
const handleSessionUpdate = useCallback((session: AuthSession): void => { const handleSessionUpdate = useCallback((session: AuthSession): void => {
setAuthSession(session) const nextSession: AuthSession = {
if (window.localStorage.getItem(AUTH_SESSION_KEY)) { ...session,
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) user: {
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) { ...session.user,
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) 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 => { const requestLogout = useCallback((accessToken?: string): void => {
void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, { void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, {
@@ -48,6 +57,17 @@ function App(): React.JSX.Element {
setAuthSession(null) setAuthSession(null)
}, [authSession?.accessToken, requestLogout]) }, [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(() => { useEffect(() => {
if (!authSession?.accessToken) return if (!authSession?.accessToken) return
@@ -79,20 +99,46 @@ function App(): React.JSX.Element {
if (session?.accessToken) { if (session?.accessToken) {
handleSessionUpdate(session) handleSessionUpdate(session)
} else { } else {
handleLogout() handleSessionExpired()
} }
}) })
.catch(() => { .catch(() => {
handleLogout() handleSessionExpired()
}) })
}, refreshDelayMs) }, refreshDelayMs)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [authSession, handleLogout, handleSessionUpdate]) }, [authSession, handleSessionExpired, handleSessionUpdate])
return authSession return (
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} /> <>
: <Login onLogin={setAuthSession} /> {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 export default App
+143 -8
View File
@@ -72,6 +72,7 @@ interface SidebarProps {
launcherUpdateMessage: string launcherUpdateMessage: string
onLauncherUpdate: () => void onLauncherUpdate: () => void
authAccessToken: string authAccessToken: string
authLoginMethod?: 'GOOGLE' | 'PASSWORD'
tailscaleIssue?: { tailscaleIssue?: {
state?: 'checking' | 'warning' state?: 'checking' | 'warning'
title: string 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 [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())
@@ -396,6 +397,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
primaryAffiliation={primaryAffiliation} primaryAffiliation={primaryAffiliation}
currentLicense={currentLicense} currentLicense={currentLicense}
accessToken={authAccessToken} accessToken={authAccessToken}
loginMethod={authLoginMethod}
avatarObjectUrl={avatarObjectUrl} avatarObjectUrl={avatarObjectUrl}
avatarMissing={avatarMissing} avatarMissing={avatarMissing}
onAvatarMissingChange={setAvatarMissing} onAvatarMissingChange={setAvatarMissing}
@@ -408,7 +410,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
)} )}
{modal === 'platforms' && ( {modal === 'platforms' && (
<PlatformManagementModal onClose={() => setModal(null)} /> <PlatformManagementModal accessToken={authAccessToken} onClose={() => setModal(null)} />
)} )}
{modal === 'settings' && ( {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 [loading, setLoading] = useState(true)
const [installing, setInstalling] = useState(false) const [installing, setInstalling] = useState(false)
const [tailscaleMissing, setTailscaleMissing] = useState(false) const [tailscaleMissing, setTailscaleMissing] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [platforms, setPlatforms] = useState<VpnPlatformPeer[]>([]) const [platforms, setPlatforms] = useState<VpnPlatformPeer[]>([])
const [selectedId, setSelectedId] = 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<void> => { const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
setLoading(true) setLoading(true)
@@ -483,6 +490,39 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
} }
}, [loadPlatforms]) }, [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> => { const installTailscale = async (): Promise<void> => {
if (!window.launcherTailscale?.install) return if (!window.launcherTailscale?.install) return
setInstalling(true) setInstalling(true)
@@ -501,6 +541,55 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
const selectedPlatform = platforms.find((platform) => platform.id === selectedId) ?? platforms[0] 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 ( return (
<ModalShell <ModalShell
title="플랫폼 관리" title="플랫폼 관리"
@@ -569,6 +658,41 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
</aside> </aside>
<section className="min-h-0 overflow-y-auto px-6 py-5"> <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 ? ( {tailscaleMissing ? (
<div className="grid h-full place-items-center px-8 text-center"> <div className="grid h-full place-items-center px-8 text-center">
<div> <div>
@@ -585,7 +709,7 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
</div> </div>
</div> </div>
) : !selectedPlatform ? ( ) : !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"> <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 initial: string
userName: string userName: string
userEmail: string userEmail: string
@@ -671,6 +795,7 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
primaryAffiliation?: SidebarProps['primaryAffiliation'] primaryAffiliation?: SidebarProps['primaryAffiliation']
currentLicense?: SidebarProps['currentLicense'] currentLicense?: SidebarProps['currentLicense']
accessToken: string accessToken: string
loginMethod?: 'GOOGLE' | 'PASSWORD'
avatarObjectUrl: string | null avatarObjectUrl: string | null
avatarMissing: boolean avatarMissing: boolean
onAvatarMissingChange: (missing: boolean) => void onAvatarMissingChange: (missing: boolean) => void
@@ -698,6 +823,7 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
const licensePeriod = currentLicense const licensePeriod = currentLicense
? `${formatDate(currentLicense.startsAt)} ~ ${currentLicense.expiresAt ? formatDate(currentLicense.expiresAt) : '무기한'}` ? `${formatDate(currentLicense.startsAt)} ~ ${currentLicense.expiresAt ? formatDate(currentLicense.expiresAt) : '무기한'}`
: '-' : '-'
const isGoogleLogin = loginMethod === 'GOOGLE'
const resetPasswordForm = (): void => { const resetPasswordForm = (): void => {
setCurrentPassword('') setCurrentPassword('')
@@ -916,8 +1042,10 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
<SecurityAction <SecurityAction
icon={<KeyRound size={15} />} icon={<KeyRound size={15} />}
title="비밀번호 변경" title="비밀번호 변경"
description="현재 비밀번호 확인 후 새 비밀번호 설정." description={isGoogleLogin ? 'Google 로그인 계정은 비밀번호를 변경할 수 없습니다.' : '현재 비밀번호 확인 후 새 비밀번호 설정.'}
disabled={isGoogleLogin}
onClick={() => { onClick={() => {
if (isGoogleLogin) return
resetPasswordForm() resetPasswordForm()
setPasswordMode(true) 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 ( 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="text-white/55">{icon}</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-[13px] font-semibold text-[var(--text-primary)]">{title}</span> <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) 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> { async function resizeImageToWebp(file: File, size: number): Promise<Blob> {
const dataUrl = await new Promise<string>((resolve, reject) => { const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
+11 -7
View File
@@ -12,6 +12,7 @@ interface DashboardProps {
authSession: AuthSession authSession: AuthSession
onSessionUpdate: (session: AuthSession) => void onSessionUpdate: (session: AuthSession) => void
onLogout: () => void onLogout: () => void
onSessionExpired: () => void
} }
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') 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 [apps, setApps] = useState<LauncherApp[]>([])
const [activeApp, setActiveApp] = useState('') const [activeApp, setActiveApp] = useState('')
const [appNotices, setAppNotices] = useState<AppNotice[]>([]) const [appNotices, setAppNotices] = useState<AppNotice[]>([])
@@ -211,13 +212,13 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
const refreshed = await refreshSession() const refreshed = await refreshSession()
if (!refreshed) { if (!refreshed) {
onLogout() onSessionExpired()
return response return response
} }
response = await request(refreshed.accessToken) response = await request(refreshed.accessToken)
return response return response
}, [authSession.accessToken, onLogout, refreshSession]) }, [authSession.accessToken, onSessionExpired, refreshSession])
useEffect(() => { useEffect(() => {
const launcherTailscale = window.launcherTailscale const launcherTailscale = window.launcherTailscale
@@ -373,7 +374,10 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
if (!cancelled && payload?.user) { if (!cancelled && payload?.user) {
const nextSession = { const nextSession = {
...session, ...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) { if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) {
return return
@@ -727,8 +731,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
onSessionUpdate(nextSession) onSessionUpdate(nextSession)
response = await requestHandoff(nextSession.accessToken) response = await requestHandoff(nextSession.accessToken)
} else { } else {
alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.') onSessionExpired()
onLogout()
return null return null
} }
} }
@@ -742,7 +745,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
const handoff = await response.json() as { handoffToken: string } const handoff = await response.json() as { handoffToken: string }
return handoff.handoffToken return handoff.handoffToken
}, [authSession.accessToken, onLogout, onSessionUpdate]) }, [authSession.accessToken, onSessionExpired, onSessionUpdate])
const handleLaunch = useCallback(async () => { const handleLaunch = useCallback(async () => {
if (!appConfig || !canLaunch || launchingApp) return if (!appConfig || !canLaunch || launchingApp) return
@@ -840,6 +843,7 @@ 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}
authLoginMethod={authSession.user.loginMethod}
tailscaleIssue={tailscaleIssue} tailscaleIssue={tailscaleIssue}
onTailscaleRetry={() => setTailscaleRetryNonce((value) => value + 1)} onTailscaleRetry={() => setTailscaleRetryNonce((value) => value + 1)}
/> />
+5 -3
View File
@@ -20,6 +20,7 @@ export interface AuthSession {
email: string email: string
name: string | null name: string | null
role: string role: string
loginMethod?: 'GOOGLE' | 'PASSWORD'
roles?: string[] roles?: string[]
createdAt?: string createdAt?: string
lastLoginAt?: string | null lastLoginAt?: string | null
@@ -87,7 +88,8 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
if (initialEmail) setEmail(initialEmail) if (initialEmail) setEmail(initialEmail)
}, [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()) storeLastLoginEmail(loginEmail.trim())
if (persistLogin) { if (persistLogin) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) 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 로그인 세션을 확인할 수 없습니다.') if (!response.ok) throw new Error('Google 로그인 세션을 확인할 수 없습니다.')
const session = await response.json() as AuthSession 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) { } catch (oauthError) {
setError(oauthError instanceof Error ? oauthError.message : 'Google 로그인에 실패했습니다.') setError(oauthError instanceof Error ? oauthError.message : 'Google 로그인에 실패했습니다.')
} finally { } finally {
@@ -183,7 +185,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
throw new Error('이메일 또는 비밀번호를 확인하세요.') throw new Error('이메일 또는 비밀번호를 확인하세요.')
} }
const session = await response.json() as AuthSession const session = await response.json() as AuthSession
await completeLogin(session, email, keepLogin, startedAt) await completeLogin(session, email, keepLogin, startedAt, 'PASSWORD')
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.') setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
} finally { } finally {