add google login flow
This commit is contained in:
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autonomy-studio-launcher",
|
"name": "autonomy-studio-launcher",
|
||||||
"version": "1.0.14",
|
"version": "1.0.15",
|
||||||
"description": "Autonomy Studio v2 Frontend",
|
"description": "Autonomy Studio v2 Frontend",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -27,6 +27,14 @@
|
|||||||
"appId": "com.autonomystudio.launcher",
|
"appId": "com.autonomystudio.launcher",
|
||||||
"productName": "Autonomy Studio Launcher",
|
"productName": "Autonomy Studio Launcher",
|
||||||
"artifactName": "autonomy-studio-launcher-${version}.${ext}",
|
"artifactName": "autonomy-studio-launcher-${version}.${ext}",
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"name": "Autonomy Studio Launcher",
|
||||||
|
"schemes": [
|
||||||
|
"autonomy-studio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
|
|||||||
+137
-29
@@ -3,7 +3,7 @@ import { join, dirname, resolve, relative, isAbsolute } from 'path'
|
|||||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
||||||
import { get as httpGet } from 'http'
|
import { get as httpGet } from 'http'
|
||||||
import { get as httpsGet } from 'https'
|
import { get as httpsGet } from 'https'
|
||||||
import { spawn, exec, ChildProcess } from 'child_process'
|
import { spawn, exec, execFile, ChildProcess } from 'child_process'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
@@ -11,6 +11,7 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
])
|
])
|
||||||
|
|
||||||
let splash: BrowserWindow | null = null
|
let splash: BrowserWindow | null = null
|
||||||
|
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
|
||||||
|
|
||||||
type CommandResult = {
|
type CommandResult = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -57,6 +58,77 @@ function getLauncherDir(): string {
|
|||||||
return dirname(process.execPath)
|
return dirname(process.execPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchGoogleOAuthPayload(payload: { loginToken?: string; error?: string }): void {
|
||||||
|
const windows = BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed())
|
||||||
|
if (windows.length === 0) {
|
||||||
|
pendingGoogleOAuthPayload = payload
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingGoogleOAuthPayload = null
|
||||||
|
for (const win of windows) {
|
||||||
|
win.webContents.send('google-oauth-callback', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProtocolUrl(rawUrl: string): void {
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== 'autonomy-studio:' || url.hostname !== 'auth' || url.pathname !== '/google/callback') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchGoogleOAuthPayload({
|
||||||
|
loginToken: url.searchParams.get('loginToken') ?? undefined,
|
||||||
|
error: url.searchParams.get('error') ?? undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeDesktopExecValue(value: string): string {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerLinuxProtocolHandler(): void {
|
||||||
|
if (process.platform !== 'linux') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const desktopFileName = is.dev
|
||||||
|
? 'autonomy-studio-launcher-dev.desktop'
|
||||||
|
: 'autonomy-studio-launcher.desktop'
|
||||||
|
const applicationsDir = join(app.getPath('home'), '.local', 'share', 'applications')
|
||||||
|
const desktopFilePath = join(applicationsDir, desktopFileName)
|
||||||
|
const iconPath = is.dev
|
||||||
|
? join(process.cwd(), 'resources/icon.png')
|
||||||
|
: join(dirname(process.execPath), 'resources/icon.png')
|
||||||
|
const execArgs = process.defaultApp && process.argv[1]
|
||||||
|
? `"${escapeDesktopExecValue(process.execPath)}" "${escapeDesktopExecValue(resolve(process.argv[1]))}" %u`
|
||||||
|
: `"${escapeDesktopExecValue(process.execPath)}" %u`
|
||||||
|
|
||||||
|
mkdirSync(applicationsDir, { recursive: true })
|
||||||
|
writeFileSync(desktopFilePath, [
|
||||||
|
'[Desktop Entry]',
|
||||||
|
'Type=Application',
|
||||||
|
'Name=Autonomy Studio Launcher',
|
||||||
|
`Exec=${execArgs}`,
|
||||||
|
`Icon=${iconPath}`,
|
||||||
|
'Terminal=false',
|
||||||
|
'NoDisplay=true',
|
||||||
|
'MimeType=x-scheme-handler/autonomy-studio;',
|
||||||
|
''
|
||||||
|
].join('\n'), 'utf8')
|
||||||
|
|
||||||
|
execFile('xdg-mime', ['default', desktopFileName, 'x-scheme-handler/autonomy-studio'], () => undefined)
|
||||||
|
execFile('update-desktop-database', [applicationsDir], () => undefined)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to register autonomy-studio protocol handler', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getUserAppsDir(): string {
|
function getUserAppsDir(): string {
|
||||||
return join(app.getPath('home'), '.local', 'share', 'autonomy-studio', 'apps')
|
return join(app.getPath('home'), '.local', 'share', 'autonomy-studio', 'apps')
|
||||||
}
|
}
|
||||||
@@ -711,6 +783,9 @@ function createWindow(): void {
|
|||||||
splash?.close()
|
splash?.close()
|
||||||
splash = null
|
splash = null
|
||||||
mainWindow.show()
|
mainWindow.show()
|
||||||
|
if (pendingGoogleOAuthPayload) {
|
||||||
|
dispatchGoogleOAuthPayload(pendingGoogleOAuthPayload)
|
||||||
|
}
|
||||||
}, 1500)
|
}, 1500)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -853,6 +928,7 @@ function createWindow(): void {
|
|||||||
|
|
||||||
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
|
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
|
||||||
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
|
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
|
||||||
|
ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url))
|
||||||
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
||||||
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -1055,32 +1131,64 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||||
electronApp.setAppUserModelId('com.electron')
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.exit(0)
|
||||||
protocol.handle('appdocs', (request) => {
|
} else {
|
||||||
const url = new URL(request.url)
|
if (process.defaultApp && process.argv.length >= 2) {
|
||||||
const docsBase = is.dev
|
app.setAsDefaultProtocolClient('autonomy-studio', process.execPath, [resolve(process.argv[1])])
|
||||||
? join(process.cwd(), 'resources/docs')
|
} else {
|
||||||
: join(__dirname, '../renderer/docs')
|
app.setAsDefaultProtocolClient('autonomy-studio')
|
||||||
const filePath = join(docsBase, url.pathname)
|
|
||||||
return net.fetch(`file://${filePath}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
|
||||||
optimizer.watchWindowShortcuts(window)
|
|
||||||
})
|
|
||||||
|
|
||||||
createSplashWindow()
|
|
||||||
createWindow()
|
|
||||||
|
|
||||||
app.on('activate', function () {
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
app.quit()
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
const protocolUrl = argv.find((arg) => arg.startsWith('autonomy-studio://'))
|
||||||
|
if (protocolUrl) handleProtocolUrl(protocolUrl)
|
||||||
|
|
||||||
|
const window = BrowserWindow.getAllWindows()[0]
|
||||||
|
if (window) {
|
||||||
|
if (window.isMinimized()) window.restore()
|
||||||
|
window.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('open-url', (event, url) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleProtocolUrl(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const arg of process.argv) {
|
||||||
|
if (arg.startsWith('autonomy-studio://')) handleProtocolUrl(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
electronApp.setAppUserModelId('com.autonomystudio.launcher')
|
||||||
|
registerLinuxProtocolHandler()
|
||||||
|
|
||||||
|
protocol.handle('appdocs', (request) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const docsBase = is.dev
|
||||||
|
? join(process.cwd(), 'resources/docs')
|
||||||
|
: join(__dirname, '../renderer/docs')
|
||||||
|
const filePath = join(docsBase, url.pathname)
|
||||||
|
return net.fetch(`file://${filePath}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
optimizer.watchWindowShortcuts(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
createSplashWindow()
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
app.on('activate', function () {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ const launcherTailscale = {
|
|||||||
const launcherSshKey = {
|
const launcherSshKey = {
|
||||||
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
|
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
|
||||||
}
|
}
|
||||||
|
const launcherAuth = {
|
||||||
|
startGoogleLogin: (url: string) => ipcRenderer.invoke('start-google-login', url),
|
||||||
|
onGoogleOAuthCallback: (callback: (payload: { loginToken?: string; error?: string }) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, payload: { loginToken?: string; error?: string }) => callback(payload)
|
||||||
|
ipcRenderer.on('google-oauth-callback', listener)
|
||||||
|
return () => ipcRenderer.removeListener('google-oauth-callback', listener)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
@@ -27,6 +35,7 @@ if (process.contextIsolated) {
|
|||||||
contextBridge.exposeInMainWorld('appWindowMeta', appWindowMeta)
|
contextBridge.exposeInMainWorld('appWindowMeta', appWindowMeta)
|
||||||
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
|
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
|
||||||
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
|
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
|
||||||
|
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -39,4 +48,6 @@ if (process.contextIsolated) {
|
|||||||
window.launcherTailscale = launcherTailscale
|
window.launcherTailscale = launcherTailscale
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.launcherSshKey = launcherSshKey
|
window.launcherSshKey = launcherSshKey
|
||||||
|
// @ts-ignore
|
||||||
|
window.launcherAuth = launcherAuth
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,39 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLogout = useCallback((): void => {
|
const requestLogout = useCallback((accessToken?: string): void => {
|
||||||
const accessToken = authSession?.accessToken
|
|
||||||
void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, {
|
void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
}).catch(() => undefined)
|
}).catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogout = useCallback((): void => {
|
||||||
|
requestLogout(authSession?.accessToken)
|
||||||
window.launcherTailscale?.logout().catch(() => undefined)
|
window.launcherTailscale?.logout().catch(() => undefined)
|
||||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
setAuthSession(null)
|
setAuthSession(null)
|
||||||
}, [authSession?.accessToken])
|
}, [authSession?.accessToken, requestLogout])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authSession?.accessToken) return
|
||||||
|
|
||||||
|
const accessToken = authSession.accessToken
|
||||||
|
const handleSessionClose = (): void => {
|
||||||
|
requestLogout(accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', handleSessionClose)
|
||||||
|
window.addEventListener('beforeunload', handleSessionClose)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', handleSessionClose)
|
||||||
|
window.removeEventListener('beforeunload', handleSessionClose)
|
||||||
|
}
|
||||||
|
}, [authSession?.accessToken, requestLogout])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession?.accessToken) return
|
if (!authSession?.accessToken) return
|
||||||
|
|||||||
Vendored
+6
@@ -52,6 +52,12 @@ declare global {
|
|||||||
privateKeyPath: string
|
privateKeyPath: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
launcherAuth?: {
|
||||||
|
startGoogleLogin: (url: string) => Promise<void>
|
||||||
|
onGoogleOAuthCallback: (
|
||||||
|
callback: (payload: { loginToken?: string; error?: string }) => void
|
||||||
|
) => () => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ export interface AuthSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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(/\/+$/, '')
|
||||||
|
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 AUTH_SESSION_KEY = 'atnmy.auth.session'
|
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||||
@@ -202,6 +203,58 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
|||||||
onLogin(session)
|
onLogin(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.launcherAuth?.onGoogleOAuthCallback(async (payload) => {
|
||||||
|
setPendingTailscaleConfirm(null)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (payload.error || !payload.loginToken) {
|
||||||
|
setLoading(false)
|
||||||
|
onLoadingChange(false)
|
||||||
|
setError('Google 로그인에 실패했습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
onLoadingChange(true)
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/oauth/exchange`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ loginToken: payload.loginToken }),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Google 로그인 세션을 확인할 수 없습니다.')
|
||||||
|
|
||||||
|
const session = await response.json() as AuthSession
|
||||||
|
await completeLogin(session, session.user.email, keepLogin, startedAt)
|
||||||
|
} catch (oauthError) {
|
||||||
|
setError(oauthError instanceof Error ? oauthError.message : 'Google 로그인에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
onLoadingChange(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => unsubscribe?.()
|
||||||
|
}, [completeLogin, keepLogin, onLoadingChange])
|
||||||
|
|
||||||
|
const handleGoogleLogin = async (): Promise<void> => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
onLoadingChange(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`)
|
||||||
|
} catch {
|
||||||
|
setLoading(false)
|
||||||
|
onLoadingChange(false)
|
||||||
|
setError('Google 로그인 창을 열 수 없습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (emailError) return
|
if (emailError) return
|
||||||
@@ -327,7 +380,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
|||||||
<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-6">
|
<button type="button" onClick={handleGoogleLogin} className="login-google-btn w-full mb-6" disabled={loading}>
|
||||||
<GoogleIcon />
|
<GoogleIcon />
|
||||||
Google로 로그인
|
Google로 로그인
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user