modify provisioning..
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user