Compare commits
20 Commits
1fda517a0b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| af5ee0299d | |||
| 9bbad61fc5 | |||
| 3bf433a129 | |||
| 9e7f4b02c7 | |||
| 4ca0575b32 | |||
| 5099ca74e0 | |||
| 3b6e2787c7 | |||
| ffd6ae9437 | |||
| 4841a699b1 | |||
| d033697117 | |||
| f3b49f7a7c | |||
| 02477574a3 | |||
| 57b98c65b9 | |||
| b1e31f5e3a | |||
| bc740eb49b | |||
| 863d675270 | |||
| 37fd0dd352 | |||
| 0b4d8d2cbb | |||
| fc0dfa310a | |||
| 0a8890cfb9 |
+19
@@ -5,3 +5,22 @@ out/
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Direct `tsc -p` output. Electron Vite owns application compilation.
|
||||
/electron.vite.config.js
|
||||
/electron.vite.config.d.ts
|
||||
/src/main/*.js
|
||||
/src/main/*.d.ts
|
||||
/src/main/**/*.js
|
||||
/src/main/**/*.d.ts
|
||||
/src/preload/*.js
|
||||
/src/preload/*.d.ts
|
||||
/src/preload/**/*.js
|
||||
/src/preload/**/*.d.ts
|
||||
/src/renderer/src/*.js
|
||||
/src/renderer/src/*.jsx
|
||||
/src/renderer/src/*.d.ts
|
||||
/src/renderer/src/**/*.js
|
||||
/src/renderer/src/**/*.jsx
|
||||
/src/renderer/src/**/*.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -11,24 +11,12 @@ cleanup_managed_tailscale() {
|
||||
rm -f "$marker"
|
||||
}
|
||||
|
||||
cleanup_apps_dir() {
|
||||
apps_dir="$1/.local/share/autonomy-studio/apps"
|
||||
[ -d "$apps_dir" ] || return 0
|
||||
|
||||
find "$apps_dir" -maxdepth 1 -type f \( \
|
||||
-name "*.AppImage" -o \
|
||||
-name "*.AppImage.version" -o \
|
||||
-name "*.download" \
|
||||
\) -delete
|
||||
|
||||
rmdir "$apps_dir" 2>/dev/null || true
|
||||
}
|
||||
case "$1" in
|
||||
upgrade|failed-upgrade|abort-install|abort-upgrade)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
cleanup_managed_tailscale
|
||||
|
||||
for home_dir in /home/*; do
|
||||
[ -d "$home_dir" ] || continue
|
||||
cleanup_apps_dir "$home_dir"
|
||||
done
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -8,9 +8,13 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
||||
build: { rollupOptions: { input: resolve('src/main/index.ts') } },
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
preload: {
|
||||
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
||||
build: { rollupOptions: { input: resolve('src/preload/index.ts') } },
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
@@ -19,6 +23,7 @@ export default defineConfig({
|
||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
|
||||
Generated
+19
-2
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "autonomy-studio-launcher",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "autonomy-studio-launcher",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.27",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"tailwindcss": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -8307,6 +8308,22 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty/node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "autonomy-studio-launcher",
|
||||
"version": "1.0.15",
|
||||
"version": "1.1.5",
|
||||
"description": "Autonomy Studio v2 Frontend",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
@@ -14,13 +14,13 @@
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"start": "electron-vite preview --config electron.vite.config.ts",
|
||||
"dev": "electron-vite dev --config electron.vite.config.ts",
|
||||
"build": "npm run typecheck && electron-vite build --config electron.vite.config.ts",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:mac": "electron-vite build --config electron.vite.config.ts && electron-builder --mac",
|
||||
"build:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"build": {
|
||||
@@ -58,6 +58,7 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"tailwindcss": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const ts = require('typescript')
|
||||
const source = readFileSync(join(__dirname, '../src/main/appImageFuse.ts'), 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const api = {}
|
||||
new Function('exports', compiled)(api)
|
||||
const cache = 'libfuse.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/libfuse.so.2'
|
||||
|
||||
function fixture(options = {}) {
|
||||
let installed = Boolean(options.installed)
|
||||
const calls = []
|
||||
const prepare = api.createFusePreparation({
|
||||
platform: options.platform ?? 'linux', arch: 'x64',
|
||||
readOsRelease: () => `ID=ubuntu\nVERSION_ID="${options.version ?? '24.04'}"\nPRETTY_NAME="Ubuntu"`,
|
||||
confirm: async () => { calls.push('confirm'); return options.accept !== false },
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
if (command === '/sbin/ldconfig') return { stdout: installed ? cache : '', stderr: '' }
|
||||
if (command === '/usr/bin/apt-cache') return { stdout: options.noCandidate ? 'Candidate: (none)' : 'Candidate: 2.9.9', stderr: '' }
|
||||
if (command === 'pkexec') {
|
||||
if (options.fail) throw new Error('authentication cancelled')
|
||||
installed = options.stillMissing !== true
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected command ${command}`)
|
||||
},
|
||||
})
|
||||
return { prepare, calls }
|
||||
}
|
||||
|
||||
test('FUSE 3 and a foreign architecture do not satisfy FUSE 2', () => {
|
||||
assert.equal(api.hasFuseLibrary(cache, 'x64'), true)
|
||||
assert.equal(api.hasFuseLibrary(cache, 'arm64'), false)
|
||||
assert.equal(api.hasFuseLibrary('libfuse3.so.3 (libc6,x86-64)', 'x64'), false)
|
||||
})
|
||||
test('existing library and non-Linux skip installation', async () => {
|
||||
for (const options of [{ installed: true }, { platform: 'darwin' }]) {
|
||||
const f = fixture(options)
|
||||
await f.prepare()
|
||||
assert.equal(f.calls.includes('confirm'), false)
|
||||
}
|
||||
})
|
||||
test('Ubuntu versions choose the appropriate package and recheck installation', async () => {
|
||||
for (const [version, pkg] of [['22.04', 'libfuse2'], ['24.04', 'libfuse2t64'], ['26.04', 'libfuse2t64']]) {
|
||||
const f = fixture({ version })
|
||||
await f.prepare()
|
||||
assert.deepEqual(f.calls.find((call) => call[0] === 'pkexec'), ['pkexec', '/usr/bin/apt-get', 'install', '-y', '--no-remove', pkg])
|
||||
assert.equal(f.calls.filter((call) => call[0] === '/sbin/ldconfig').length, 2)
|
||||
}
|
||||
})
|
||||
test('cancel does not install and can be retried', async () => {
|
||||
const f = fixture({ accept: false })
|
||||
await assert.rejects(f.prepare(), /취소/)
|
||||
await assert.rejects(f.prepare(), /취소/)
|
||||
assert.equal(f.calls.some((call) => call[0] === 'pkexec'), false)
|
||||
})
|
||||
test('missing candidate, failed authentication and failed postcheck reject', async () => {
|
||||
for (const options of [{ noCandidate: true }, { fail: true }, { stillMissing: true }]) {
|
||||
await assert.rejects(fixture(options).prepare())
|
||||
}
|
||||
})
|
||||
test('concurrent preparations share one prompt and installation', async () => {
|
||||
const f = fixture()
|
||||
await Promise.all([f.prepare(), f.prepare()])
|
||||
assert.equal(f.calls.filter((call) => call === 'confirm').length, 1)
|
||||
assert.equal(f.calls.filter((call) => call[0] === 'pkexec').length, 1)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
export type FusePackage = 'libfuse2' | 'libfuse2t64'
|
||||
|
||||
export function parseOsRelease(content: string): Record<string, string> {
|
||||
return Object.fromEntries(content.split('\n').flatMap((line) => {
|
||||
const match = line.match(/^([A-Z_]+)=(.*)$/)
|
||||
return match ? [[match[1], match[2].replace(/^(["'])(.*)\1$/, '$2')]] : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function fusePackageCandidates(os: Record<string, string>): FusePackage[] {
|
||||
const modern = (os.ID === 'ubuntu' && Number.parseFloat(os.VERSION_ID) >= 24.04)
|
||||
|| (os.ID === 'debian' && Number.parseFloat(os.VERSION_ID) >= 13)
|
||||
return modern ? ['libfuse2t64', 'libfuse2'] : ['libfuse2', 'libfuse2t64']
|
||||
}
|
||||
|
||||
export function hasFuseLibrary(cache: string, arch: string): boolean {
|
||||
const architecture = arch === 'x64' ? 'x86-64' : arch === 'arm64' ? 'AArch64' : null
|
||||
return cache.split('\n').some((line) => /^\s*libfuse\.so\.2\s/.test(line)
|
||||
&& (!architecture || line.includes(architecture)))
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
platform: string
|
||||
arch: string
|
||||
readOsRelease: () => string
|
||||
run: (command: string, args: string[], timeout?: number) => Promise<{ stdout: string; stderr: string }>
|
||||
confirm: (details: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function createFusePreparation(deps: Dependencies): () => Promise<void> {
|
||||
let pending: Promise<void> | null = null
|
||||
const ready = async (): Promise<boolean> => {
|
||||
const result = await deps.run('/sbin/ldconfig', ['-p'], 10000)
|
||||
return hasFuseLibrary(result.stdout, deps.arch)
|
||||
}
|
||||
const prepare = async (): Promise<void> => {
|
||||
if (deps.platform !== 'linux' || await ready()) return
|
||||
const os = parseOsRelease(deps.readOsRelease())
|
||||
if (![os.ID, ...(os.ID_LIKE ?? '').split(/\s+/)].some((id) => id === 'ubuntu' || id === 'debian')) {
|
||||
throw new Error('LMS 실행에 libfuse.so.2가 필요합니다. 이 운영체제에서는 자동 설치를 지원하지 않습니다. 배포판의 FUSE 2 라이브러리를 설치한 뒤 다시 실행하세요.')
|
||||
}
|
||||
let selected: FusePackage | undefined
|
||||
for (const candidate of fusePackageCandidates(os)) {
|
||||
const result = await deps.run('/usr/bin/apt-cache', ['policy', candidate], 10000)
|
||||
if (/Candidate:\s*(?!\(none\))\S+/.test(result.stdout)) {
|
||||
selected = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!selected) throw new Error('FUSE 2 설치 패키지를 찾지 못했습니다. 시스템 패키지 목록과 Ubuntu 저장소 설정을 확인한 뒤 다시 시도하세요.')
|
||||
if (!await deps.confirm(`${os.PRETTY_NAME ?? os.ID}\n\nLMS 실행에 필요한 ${selected} 라이브러리가 없습니다. 설치를 누르면 관리자 인증 후 설치합니다.`)) {
|
||||
throw new Error('필수 구성요소 설치를 취소했습니다. 다음 설치 또는 실행 시 다시 진행할 수 있습니다.')
|
||||
}
|
||||
try {
|
||||
await deps.run('pkexec', ['/usr/bin/apt-get', 'install', '-y', '--no-remove', selected], 600000)
|
||||
} catch (error) {
|
||||
throw new Error(`필수 구성요소 설치가 완료되지 않았습니다. 관리자 인증 취소, 네트워크 또는 패키지 관리자 잠금 상태를 확인하세요.\n${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
if (!await ready()) throw new Error('설치 후에도 libfuse.so.2를 확인하지 못했습니다. 시스템 패키지 상태를 확인한 뒤 다시 시도하세요.')
|
||||
}
|
||||
return () => {
|
||||
if (!pending) pending = prepare().finally(() => { pending = null })
|
||||
return pending
|
||||
}
|
||||
}
|
||||
+200
-23
@@ -5,6 +5,8 @@ import { get as httpGet } from 'http'
|
||||
import { get as httpsGet } from 'https'
|
||||
import { spawn, exec, execFile, ChildProcess } from 'child_process'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { LmsWebBridge } from './lms-web-bridge'
|
||||
import { createFusePreparation } from './appImageFuse'
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
||||
@@ -12,6 +14,36 @@ protocol.registerSchemesAsPrivileged([
|
||||
|
||||
let splash: BrowserWindow | null = null
|
||||
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
|
||||
const lmsWebBridge = new LmsWebBridge()
|
||||
const prepareLmsFuse = createFusePreparation({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
readOsRelease: () => readFileSync('/etc/os-release', 'utf8'),
|
||||
run: (command, args, timeout) => runCommand(command, args, timeout, { LC_ALL: 'C', LANG: 'C' }),
|
||||
confirm: async (detail) => {
|
||||
const options = {
|
||||
type: 'info' as const,
|
||||
title: 'LMS 실행 환경 준비',
|
||||
message: '필수 구성요소를 설치하시겠습니까?',
|
||||
detail,
|
||||
buttons: ['설치', '취소'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
}
|
||||
const parent = BrowserWindow.getFocusedWindow()
|
||||
const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options)
|
||||
return result.response === 0
|
||||
},
|
||||
})
|
||||
|
||||
async function prepareAppRuntime(payload: { appName: string; execPath?: string; devAppPath?: string }): Promise<void> {
|
||||
if (payload.appName.trim().toLowerCase() !== 'lms') return
|
||||
const devPath = resolveDevAppPath(payload.devAppPath) ?? getDefaultDevAppPath(payload.appName)
|
||||
if (is.dev && devPath && isRunnableDevAppPath(devPath)) return
|
||||
const path = resolveExecPath(payload.execPath, payload.appName)
|
||||
if (path?.toLowerCase().endsWith('.appimage')) await prepareLmsFuse()
|
||||
}
|
||||
|
||||
type CommandResult = {
|
||||
ok: boolean
|
||||
@@ -318,12 +350,17 @@ function downloadFile(
|
||||
|
||||
const total = Number(response.headers['content-length'] ?? 0)
|
||||
let received = 0
|
||||
let lastProgress = -1
|
||||
const file = createWriteStream(tmpPath)
|
||||
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length
|
||||
if (total > 0) {
|
||||
onProgress(Math.min(99, Math.round((received / total) * 100)))
|
||||
const progress = Math.min(99, Math.round((received / total) * 100))
|
||||
if (progress !== lastProgress) {
|
||||
lastProgress = progress
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -402,18 +439,41 @@ function createCleanChildEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return env
|
||||
}
|
||||
|
||||
function terminateChildProcess(child: ChildProcess): void {
|
||||
if (child.killed) return
|
||||
|
||||
const kill = (signal: NodeJS.Signals): boolean => {
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal)
|
||||
return true
|
||||
} catch {
|
||||
// Fall back to the direct child when a process group is unavailable.
|
||||
}
|
||||
}
|
||||
child.kill(signal)
|
||||
return false
|
||||
}
|
||||
|
||||
const killedProcessGroup = kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (killedProcessGroup && process.platform !== 'win32' && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL')
|
||||
return
|
||||
} catch {
|
||||
// The group already exited.
|
||||
}
|
||||
}
|
||||
if (!child.killed) child.kill('SIGKILL')
|
||||
}, 1500).unref()
|
||||
}
|
||||
|
||||
function stopChildApps(
|
||||
appProcesses: Map<string, ChildProcess>,
|
||||
appWindows: Map<string, BrowserWindow>,
|
||||
): void {
|
||||
for (const child of appProcesses.values()) {
|
||||
if (!child.killed) {
|
||||
child.kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGKILL')
|
||||
}, 1500).unref()
|
||||
}
|
||||
}
|
||||
for (const child of appProcesses.values()) terminateChildProcess(child)
|
||||
appProcesses.clear()
|
||||
|
||||
for (const win of appWindows.values()) {
|
||||
@@ -487,6 +547,38 @@ function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined
|
||||
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
|
||||
}
|
||||
|
||||
function getLauncherOsUser(): string {
|
||||
const user = (process.env.USER ?? process.env.LOGNAME ?? '').trim()
|
||||
return /^[a-z_][a-z0-9_-]*\$?$/i.test(user) ? user : ''
|
||||
}
|
||||
|
||||
async function ensureTailscaleOperator(): Promise<CommandResult> {
|
||||
const operator = getLauncherOsUser()
|
||||
if (!operator) {
|
||||
return { ok: false, message: '현재 OS 사용자를 확인하지 못했습니다.' }
|
||||
}
|
||||
|
||||
const args = ['set', `--operator=${operator}`]
|
||||
try {
|
||||
await runCommand('tailscale', args, 15000)
|
||||
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
|
||||
} catch (error) {
|
||||
if (!isTailscalePermissionError(error)) {
|
||||
return { ok: false, message: commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패') }
|
||||
}
|
||||
|
||||
try {
|
||||
await runCommand('pkexec', ['/usr/bin/tailscale', ...args], 30000)
|
||||
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
|
||||
} catch (pkexecError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패')),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installTailscale(): Promise<CommandResult> {
|
||||
const installScript = [
|
||||
'set -e',
|
||||
@@ -565,13 +657,6 @@ async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<vo
|
||||
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
|
||||
}
|
||||
|
||||
async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
|
||||
const loggedOut = await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'logout'], 30000, commandEnv)
|
||||
if (!loggedOut) {
|
||||
await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'down'], 30000, commandEnv)
|
||||
}
|
||||
}
|
||||
|
||||
async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
|
||||
const attempts: Array<[string, string[]]> = [
|
||||
['tailscale', ['status', '--json']],
|
||||
@@ -691,16 +776,24 @@ async function loginTailscale(
|
||||
} catch (error) {
|
||||
if (isTailscalePermissionError(error)) {
|
||||
try {
|
||||
if (forceReset) {
|
||||
await resetTailscaleSessionWithPkexec(commandEnv)
|
||||
}
|
||||
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
|
||||
await waitForTailscaleConnected()
|
||||
return { ok: true, message: 'Tailscale 연결 완료' }
|
||||
|
||||
// Persist unprivileged access after the one privileged recovery login.
|
||||
const operator = await ensureTailscaleOperator()
|
||||
return {
|
||||
ok: true,
|
||||
message: operator.ok
|
||||
? 'Tailscale 연결 완료'
|
||||
: 'Tailscale 연결 완료. 다음 연결을 위해 제어 권한을 다시 확인하세요.',
|
||||
}
|
||||
} catch (pkexecError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')),
|
||||
message: commandErrorMessage(
|
||||
pkexecError,
|
||||
commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다. 다시 연결해 주세요.'),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -747,6 +840,23 @@ async function logoutTailscale(): Promise<CommandResult> {
|
||||
}
|
||||
}
|
||||
|
||||
async function restartTailscaleService(): Promise<CommandResult> {
|
||||
try {
|
||||
await runCommand('pkexec', ['/usr/bin/systemctl', 'restart', 'tailscaled'], 60000)
|
||||
await delay(1000)
|
||||
const status = await readTailscaleStatus()
|
||||
return {
|
||||
ok: true,
|
||||
message: status.connected ? '원격 연결 서비스가 재시작되었습니다.' : '원격 연결 서비스가 재시작되었습니다. 연결 상태를 다시 확인하세요.',
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: commandErrorMessage(error, '원격 연결 서비스 재시작 실패'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSplashWindow(): void {
|
||||
splash = new BrowserWindow({
|
||||
width: 580,
|
||||
@@ -797,7 +907,9 @@ function createWindow(): void {
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
sandbox: false,
|
||||
// Keep token refresh and launcher background work running while child apps are open.
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
attachDevToolsShortcut(mainWindow)
|
||||
@@ -813,6 +925,10 @@ function createWindow(): void {
|
||||
}
|
||||
}, 1500)
|
||||
})
|
||||
mainWindow.on('close', () => {
|
||||
lmsWebBridge.invalidateSessions()
|
||||
stopChildApps(appProcesses, appWindows)
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
@@ -871,6 +987,10 @@ function createWindow(): void {
|
||||
const targetPath = resolveExecPath(payload.execPath, payload.appName)
|
||||
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
||||
|
||||
if (payload.appName.trim().toLowerCase() === 'lms' && targetPath.toLowerCase().endsWith('.appimage')) {
|
||||
await prepareLmsFuse()
|
||||
}
|
||||
|
||||
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
||||
event.sender.send('app-download-progress', {
|
||||
appName: payload.appName,
|
||||
@@ -954,8 +1074,32 @@ function createWindow(): void {
|
||||
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
|
||||
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
|
||||
ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url))
|
||||
ipcMain.handle('launch-web-app', async (_event, payload: { appName?: string; url?: string }) => {
|
||||
const rawUrl = payload.url?.trim()
|
||||
if (!rawUrl) throw new Error('웹 실행 주소가 없습니다.')
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new Error('웹 실행 주소 형식이 올바르지 않습니다.')
|
||||
}
|
||||
if (payload.appName?.trim().toLowerCase() === 'lms') {
|
||||
const bridge = await lmsWebBridge.createSession(url.origin)
|
||||
const hash = new URLSearchParams(url.hash.replace(/^#/, ''))
|
||||
hash.set('bridgeUrl', bridge.url)
|
||||
hash.set('bridgeToken', bridge.token)
|
||||
url.hash = hash.toString()
|
||||
}
|
||||
await shell.openExternal(url.toString())
|
||||
mainWindow.minimize()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : '웹 브라우저를 열지 못했습니다.')
|
||||
}
|
||||
})
|
||||
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
||||
ipcMain.handle('launcher-tailscale-install', () => installTailscale())
|
||||
ipcMain.handle('launcher-tailscale-ensure-operator', () => ensureTailscaleOperator())
|
||||
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
||||
ipcMain.handle(
|
||||
'launcher-tailscale-login',
|
||||
@@ -963,6 +1107,7 @@ function createWindow(): void {
|
||||
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
|
||||
)
|
||||
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
|
||||
ipcMain.handle('launcher-tailscale-restart-service', () => restartTailscaleService())
|
||||
|
||||
const onAppClosed = (appName: string): void => {
|
||||
mainWindow.webContents.send('app-window-closed', appName)
|
||||
@@ -972,7 +1117,28 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('launch-app', (_, payload: {
|
||||
ipcMain.handle('launcher-end-user-session', () => {
|
||||
lmsWebBridge.invalidateSessions()
|
||||
|
||||
for (const [appName, win] of appWindows) {
|
||||
if (appName.trim().toLowerCase() !== 'lms') continue
|
||||
if (!win.isDestroyed()) win.close()
|
||||
}
|
||||
|
||||
for (const [appName, child] of appProcesses) {
|
||||
if (appName.trim().toLowerCase() !== 'lms' || child.killed) continue
|
||||
terminateChildProcess(child)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('prepare-app-runtime', async (_event, payload: { appName: string; execPath?: string; devAppPath?: string }) => {
|
||||
await prepareAppRuntime(payload)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
ipcMain.on('launch-app', async (_, payload: {
|
||||
appName: string
|
||||
url?: string
|
||||
width?: number
|
||||
@@ -982,6 +1148,13 @@ function createWindow(): void {
|
||||
handoffCode?: string
|
||||
audienceApp?: string
|
||||
}) => {
|
||||
try {
|
||||
await prepareAppRuntime(payload)
|
||||
} catch (error) {
|
||||
mainWindow.webContents.send('app-window-closed', payload.appName)
|
||||
dialog.showErrorBox('LMS 실행 환경 준비 실패', error instanceof Error ? error.message : String(error))
|
||||
return
|
||||
}
|
||||
const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload
|
||||
const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
|
||||
const authLaunchArgs = handoffCode
|
||||
@@ -1217,4 +1390,8 @@ if (!gotSingleInstanceLock) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
void lmsWebBridge.stop()
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,11 @@ const appWindowMeta = { isAppWindow, appName, url: appUrl }
|
||||
const launcherTailscale = {
|
||||
status: () => ipcRenderer.invoke('launcher-tailscale-status'),
|
||||
install: () => ipcRenderer.invoke('launcher-tailscale-install'),
|
||||
ensureOperator: () => ipcRenderer.invoke('launcher-tailscale-ensure-operator'),
|
||||
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
|
||||
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
|
||||
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
|
||||
restartService: () => ipcRenderer.invoke('launcher-tailscale-restart-service'),
|
||||
}
|
||||
const launcherSshKey = {
|
||||
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
|
||||
@@ -29,6 +31,9 @@ const launcherAuth = {
|
||||
return () => ipcRenderer.removeListener('google-oauth-callback', listener)
|
||||
},
|
||||
}
|
||||
const launcherSession = {
|
||||
endUserSession: () => ipcRenderer.invoke('launcher-end-user-session'),
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
@@ -37,6 +42,7 @@ if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
|
||||
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
|
||||
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
||||
contextBridge.exposeInMainWorld('launcherSession', launcherSession)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -51,4 +57,6 @@ if (process.contextIsolated) {
|
||||
window.launcherSshKey = launcherSshKey
|
||||
// @ts-ignore
|
||||
window.launcherAuth = launcherAuth
|
||||
// @ts-ignore
|
||||
window.launcherSession = launcherSession
|
||||
}
|
||||
|
||||
+89
-37
@@ -1,8 +1,14 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import AppWindowShell from './pages/AppWindowShell'
|
||||
import type { AuthSession } from './pages/LoginForm'
|
||||
import {
|
||||
blockLauncherSessionRefresh,
|
||||
refreshLauncherSession,
|
||||
resetLauncherSessionRefresh,
|
||||
} from './auth/sessionRefresh'
|
||||
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||
@@ -16,6 +22,8 @@ function App(): React.JSX.Element {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const [sessionExpiredOpen, setSessionExpiredOpen] = useState(false)
|
||||
const sessionExpiredRef = useRef(false)
|
||||
|
||||
const meta = window.appWindowMeta
|
||||
if (meta?.isAppWindow) {
|
||||
@@ -23,13 +31,22 @@ 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))
|
||||
if (sessionExpiredRef.current) return
|
||||
|
||||
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`, {
|
||||
@@ -41,58 +58,93 @@ function App(): React.JSX.Element {
|
||||
}, [])
|
||||
|
||||
const handleLogout = useCallback((): void => {
|
||||
blockLauncherSessionRefresh()
|
||||
window.launcherSession?.endUserSession().catch(() => undefined)
|
||||
requestLogout(authSession?.accessToken)
|
||||
window.launcherTailscale?.logout().catch(() => undefined)
|
||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||
setAuthSession(null)
|
||||
}, [authSession?.accessToken, requestLogout])
|
||||
|
||||
const handleSessionExpired = useCallback((): void => {
|
||||
if (sessionExpiredRef.current) return
|
||||
sessionExpiredRef.current = true
|
||||
blockLauncherSessionRefresh()
|
||||
window.launcherSession?.endUserSession().catch(() => undefined)
|
||||
setSessionExpiredOpen(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.accessToken) return
|
||||
const confirmSessionExpired = useCallback((): void => {
|
||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||
setSessionExpiredOpen(false)
|
||||
setAuthSession(null)
|
||||
}, [])
|
||||
|
||||
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])
|
||||
const handleLogin = useCallback((session: AuthSession): void => {
|
||||
sessionExpiredRef.current = false
|
||||
resetLauncherSessionRefresh()
|
||||
setSessionExpiredOpen(false)
|
||||
setAuthSession(session)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.accessToken) return
|
||||
|
||||
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
|
||||
const timer = window.setTimeout(() => {
|
||||
fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
|
||||
let timer: number | null = null
|
||||
const refresh = (): void => {
|
||||
refreshLauncherSession()
|
||||
.then((session) => {
|
||||
if (session?.accessToken) {
|
||||
handleSessionUpdate(session)
|
||||
} else {
|
||||
handleLogout()
|
||||
handleSessionExpired()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
handleLogout()
|
||||
.catch((error: unknown) => {
|
||||
console.warn('[launcher] session refresh failed; retrying in 30 seconds', error)
|
||||
timer = window.setTimeout(refresh, 30_000)
|
||||
})
|
||||
}, refreshDelayMs)
|
||||
}
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [authSession, handleLogout, handleSessionUpdate])
|
||||
timer = window.setTimeout(refresh, refreshDelayMs)
|
||||
|
||||
return authSession
|
||||
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} />
|
||||
: <Login onLogin={setAuthSession} />
|
||||
return () => {
|
||||
if (timer !== null) window.clearTimeout(timer)
|
||||
}
|
||||
}, [authSession, handleSessionExpired, handleSessionUpdate])
|
||||
|
||||
return (
|
||||
<>
|
||||
{authSession
|
||||
&& !sessionExpiredOpen
|
||||
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} onSessionExpired={handleSessionExpired} />
|
||||
: <Login onLogin={handleLogin} />}
|
||||
{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
|
||||
|
||||
@@ -355,6 +355,7 @@ body,
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
min-width: 78px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
@@ -437,7 +438,7 @@ body,
|
||||
padding: 9px 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #fca5a5;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -446,6 +447,14 @@ body,
|
||||
}
|
||||
|
||||
.launch-action-dropdown button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.launch-action-dropdown .launch-action-danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.launch-action-dropdown .launch-action-danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
@@ -713,6 +722,16 @@ body,
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-version-wrap .version-label,
|
||||
.login-version-wrap .launcher-update-text {
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.login-version-wrap .launcher-update-text {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-version-wrap {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
@@ -730,8 +749,10 @@ body,
|
||||
}
|
||||
|
||||
.launcher-update-text {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AuthSession } from '../pages/LoginForm'
|
||||
|
||||
const launcherApiBaseUrl = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||
|
||||
let pendingRefresh: Promise<AuthSession | null> | null = null
|
||||
let refreshBlocked = false
|
||||
|
||||
export function blockLauncherSessionRefresh(): void {
|
||||
refreshBlocked = true
|
||||
}
|
||||
|
||||
export function resetLauncherSessionRefresh(): void {
|
||||
refreshBlocked = false
|
||||
}
|
||||
|
||||
export function refreshLauncherSession(): Promise<AuthSession | null> {
|
||||
if (refreshBlocked) return Promise.resolve(null)
|
||||
if (pendingRefresh) return pendingRefresh
|
||||
|
||||
pendingRefresh = fetch(`${launcherApiBaseUrl}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json() as Promise<AuthSession>
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
refreshBlocked = true
|
||||
return null
|
||||
}
|
||||
throw new Error(`세션 갱신 요청에 실패했습니다. (${response.status})`)
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRefresh = null
|
||||
})
|
||||
|
||||
return pendingRefresh
|
||||
}
|
||||
@@ -21,12 +21,17 @@ import {
|
||||
MonitorCog,
|
||||
Palette,
|
||||
Info,
|
||||
Network
|
||||
Network,
|
||||
Copy,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import type { LauncherApp } from '../config/apps'
|
||||
|
||||
const ICON_SIZE = 14
|
||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||
const PLATFORM_AGENT_INSTALL_URL = import.meta.env.VITE_PLATFORM_AGENT_INSTALL_URL
|
||||
|| 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent/install.sh'
|
||||
const PLATFORM_AGENT_INSTALL_COMMAND = `curl -fsSL ${PLATFORM_AGENT_INSTALL_URL} | bash`
|
||||
|
||||
const APP_ICONS: Record<string, React.ReactNode> = {
|
||||
code: <Code2 size={ICON_SIZE} />,
|
||||
@@ -72,10 +77,17 @@ interface SidebarProps {
|
||||
launcherUpdateMessage: string
|
||||
onLauncherUpdate: () => void
|
||||
authAccessToken: string
|
||||
authLoginMethod?: 'GOOGLE' | 'PASSWORD'
|
||||
tailscaleIssue?: {
|
||||
state?: 'checking' | 'warning'
|
||||
title: string
|
||||
message: string
|
||||
} | null
|
||||
onTailscaleRetry?: () => void
|
||||
}
|
||||
|
||||
type UserModal = 'profile' | 'settings' | 'platforms' | null
|
||||
type SettingsTabKey = 'general' | 'theme' | 'info'
|
||||
type SettingsTabKey = 'general' | 'remote' | 'theme' | 'info'
|
||||
type VpnPlatformPeer = {
|
||||
id: string
|
||||
hostName: string
|
||||
@@ -99,7 +111,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 }: 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())
|
||||
@@ -212,15 +224,14 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
|
||||
{collapsed && <div className="pt-4 mb-2" />}
|
||||
|
||||
{/* 네비게이션 아이템 */}
|
||||
{apps.map((item) => {
|
||||
const unavailable = item.canLaunch === false || item.canView === false
|
||||
{apps.filter((item) => item.canView !== false).map((item) => {
|
||||
const unavailable = item.canLaunch === false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.appKey}
|
||||
className={`nav-item${activeApp === item.appKey ? ' active' : ''}${collapsed ? ' nav-item-collapsed' : ''}${unavailable ? ' nav-item-unavailable' : ''}`}
|
||||
onClick={() => {
|
||||
if (unavailable) return
|
||||
onAppChange(item.appKey)
|
||||
}}
|
||||
title={collapsed ? item.displayName : undefined}
|
||||
@@ -237,6 +248,51 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
|
||||
|
||||
{/* 유저 프로필 푸터 */}
|
||||
<div className="mt-auto border-t border-[var(--border)] p-4 relative" ref={menuRef}>
|
||||
{tailscaleIssue && !collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setModal('platforms')
|
||||
}}
|
||||
className={`mb-3 w-full rounded-lg border px-3 py-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-md transition-colors ${
|
||||
tailscaleIssue.state === 'checking'
|
||||
? 'border-sky-300/18 bg-sky-300/[0.07] hover:border-sky-300/28 hover:bg-sky-300/[0.1]'
|
||||
: 'border-amber-300/18 bg-amber-300/[0.07] hover:border-amber-300/28 hover:bg-amber-300/[0.1]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className={`mt-1 size-2.5 shrink-0 rounded-full ${
|
||||
tailscaleIssue.state === 'checking'
|
||||
? 'animate-pulse bg-sky-300 shadow-[0_0_0_3px_rgba(125,211,252,0.14)]'
|
||||
: 'bg-amber-300 shadow-[0_0_0_3px_rgba(251,191,36,0.14)]'
|
||||
}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block text-[12px] font-bold ${tailscaleIssue.state === 'checking' ? 'text-sky-100/90' : 'text-amber-100/90'}`}>{tailscaleIssue.title}</span>
|
||||
<span className="mt-1 block text-[11px] font-medium leading-4 text-white/48">{tailscaleIssue.message}</span>
|
||||
</span>
|
||||
{tailscaleIssue.state !== 'checking' && onTailscaleRetry && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onTailscaleRetry()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onTailscaleRetry()
|
||||
}}
|
||||
className="mt-0.5 shrink-0 rounded-md border border-white/12 bg-white/[0.06] px-2.5 py-1 text-[11px] font-bold text-white/68 transition-colors hover:bg-white/[0.1] hover:text-white"
|
||||
>
|
||||
재시도
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 드롭업 메뉴 */}
|
||||
{menuOpen && !collapsed && (
|
||||
@@ -346,6 +402,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
|
||||
primaryAffiliation={primaryAffiliation}
|
||||
currentLicense={currentLicense}
|
||||
accessToken={authAccessToken}
|
||||
loginMethod={authLoginMethod}
|
||||
avatarObjectUrl={avatarObjectUrl}
|
||||
avatarMissing={avatarMissing}
|
||||
onAvatarMissingChange={setAvatarMissing}
|
||||
@@ -358,7 +415,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
|
||||
)}
|
||||
|
||||
{modal === 'platforms' && (
|
||||
<PlatformManagementModal onClose={() => setModal(null)} />
|
||||
<PlatformManagementModal accessToken={authAccessToken} onClose={() => setModal(null)} />
|
||||
)}
|
||||
|
||||
{modal === 'settings' && (
|
||||
@@ -367,6 +424,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
|
||||
launcherUpdating={launcherUpdating}
|
||||
launcherUpdateMessage={launcherUpdateMessage}
|
||||
onLauncherUpdate={onLauncherUpdate}
|
||||
onTailscaleRetry={onTailscaleRetry}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -374,13 +432,20 @@ 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 [registrationGuideOpen, setRegistrationGuideOpen] = useState(false)
|
||||
const [registrationGuideCopied, setRegistrationGuideCopied] = useState(false)
|
||||
|
||||
const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
|
||||
setLoading(true)
|
||||
@@ -433,6 +498,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)
|
||||
@@ -451,6 +549,65 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const copyRegistrationGuide = async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(PLATFORM_AGENT_INSTALL_COMMAND)
|
||||
setRegistrationGuideCopied(true)
|
||||
window.setTimeout(() => setRegistrationGuideCopied(false), 1600)
|
||||
} catch {
|
||||
setAuthorizationError('설치 명령을 복사하지 못했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="플랫폼 관리"
|
||||
@@ -499,16 +656,16 @@ function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JS
|
||||
key={platform.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(platform.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
className={`flex w-full min-w-0 overflow-hidden items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'border-[rgba(103,232,249,0.42)] bg-[rgba(103,232,249,0.12)]'
|
||||
: 'border-transparent hover:border-white/10 hover:bg-white/[0.05]'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-[#35d27f]' : 'bg-[#ef4444]'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-bold text-white/86">{platform.hostName}</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] font-medium text-white/42">{platform.dnsName || platform.loginName || '플랫폼 장비'}</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span title={platform.hostName} className="block truncate text-[12px] font-bold text-white/86">{platform.hostName}</span>
|
||||
<span title={platform.dnsName || platform.loginName || '플랫폼 장비'} className="mt-0.5 block truncate text-[11px] font-medium text-white/42">{platform.dnsName || platform.loginName || '플랫폼 장비'}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
@@ -519,6 +676,68 @@ 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>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createAgentRegistrationCode()}
|
||||
disabled={registrationLoading}
|
||||
className="h-7 w-[72px] cursor-pointer rounded-md bg-[#5588ff] text-[11px] font-bold text-white transition-colors hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
키 발급
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={registrationGuideOpen}
|
||||
onClick={() => setRegistrationGuideOpen((open) => !open)}
|
||||
className="h-7 w-[72px] cursor-pointer rounded-md border border-white/14 text-[11px] font-bold text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
등록 방법
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{registrationGuideOpen && (
|
||||
<div className="mt-3 rounded-md border border-white/10 bg-black/20 px-3 py-2.5">
|
||||
<p className="text-[11px] font-medium leading-5 text-white/48">플랫폼 장비의 터미널에서 아래 명령을 실행하세요.</p>
|
||||
<div className="mt-2 flex items-start gap-2">
|
||||
<code className="min-w-0 flex-1 select-all break-all font-mono text-[10px] font-medium leading-5 text-white/72">{PLATFORM_AGENT_INSTALL_COMMAND}</code>
|
||||
<button
|
||||
type="button"
|
||||
title="설치 명령 복사"
|
||||
aria-label="설치 명령 복사"
|
||||
onClick={() => void copyRegistrationGuide()}
|
||||
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md border border-white/14 text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
{registrationGuideCopied ? <Check size={13} className="text-[#85efac]" /> : <Copy size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
@@ -535,7 +754,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">
|
||||
@@ -611,7 +830,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
|
||||
@@ -621,6 +840,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
|
||||
@@ -648,6 +868,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('')
|
||||
@@ -866,8 +1087,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)
|
||||
}}
|
||||
@@ -922,19 +1145,61 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onClose }: {
|
||||
function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onTailscaleRetry, onClose }: {
|
||||
launcherUpdateAvailable: boolean
|
||||
launcherUpdating: boolean
|
||||
launcherUpdateMessage: string
|
||||
onLauncherUpdate: () => void
|
||||
onTailscaleRetry?: () => void
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTabKey>('general')
|
||||
const [clearingCache, setClearingCache] = useState(false)
|
||||
const [cacheAvailable, setCacheAvailable] = useState(true)
|
||||
const [cacheMessage, setCacheMessage] = useState('')
|
||||
const [tailscaleStatus, setTailscaleStatus] = useState<Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null>(null)
|
||||
const [tailscaleLoading, setTailscaleLoading] = useState(false)
|
||||
const [tailscaleRestarting, setTailscaleRestarting] = useState(false)
|
||||
const [tailscaleMessage, setTailscaleMessage] = useState('')
|
||||
const version = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
const loadTailscaleStatus = useCallback(async (): Promise<void> => {
|
||||
if (!window.launcherTailscale) {
|
||||
setTailscaleMessage('데스크톱 런처에서만 원격 연결 상태를 확인할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
setTailscaleLoading(true)
|
||||
try {
|
||||
const status = await window.launcherTailscale.status()
|
||||
setTailscaleStatus(status)
|
||||
setTailscaleMessage(status.error ?? '')
|
||||
} catch {
|
||||
setTailscaleMessage('원격 연결 상태를 확인하지 못했습니다.')
|
||||
} finally {
|
||||
setTailscaleLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'remote') void loadTailscaleStatus()
|
||||
}, [activeTab, loadTailscaleStatus])
|
||||
|
||||
const restartTailscaleService = async (): Promise<void> => {
|
||||
if (!window.launcherTailscale?.restartService || tailscaleRestarting) return
|
||||
setTailscaleRestarting(true)
|
||||
setTailscaleMessage('')
|
||||
try {
|
||||
const result = await window.launcherTailscale.restartService()
|
||||
setTailscaleMessage(result.message)
|
||||
if (result.ok) await loadTailscaleStatus()
|
||||
} catch {
|
||||
setTailscaleMessage('원격 연결 서비스 재시작에 실패했습니다.')
|
||||
} finally {
|
||||
setTailscaleRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = async (): Promise<void> => {
|
||||
if (!cacheAvailable) return
|
||||
|
||||
@@ -976,6 +1241,7 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
|
||||
<div className="grid h-full grid-cols-[138px_1fr]">
|
||||
<aside className="border-r border-[rgba(255,255,255,0.08)] p-3">
|
||||
<SettingsTab icon={<MonitorCog size={15} />} label="일반" active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
<SettingsTab icon={<Network size={15} />} label="원격 연결" active={activeTab === 'remote'} onClick={() => setActiveTab('remote')} />
|
||||
<SettingsTab icon={<Palette size={15} />} label="테마" active={activeTab === 'theme'} onClick={() => setActiveTab('theme')} />
|
||||
<SettingsTab icon={<Info size={15} />} label="정보" active={activeTab === 'info'} onClick={() => setActiveTab('info')} />
|
||||
</aside>
|
||||
@@ -1041,6 +1307,55 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'remote' && (
|
||||
<>
|
||||
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">원격 연결</h3>
|
||||
<SettingBlock title="연결 상태" description="현재 연결 상태">
|
||||
<div className="flex min-w-[150px] flex-col items-end gap-1.5">
|
||||
<span className={`text-[11px] font-bold ${tailscaleStatus?.connected ? 'text-[#58d68d]' : 'text-[#f3b44f]'}`}>
|
||||
{tailscaleLoading ? '확인 중' : tailscaleStatus?.connected ? '연결됨' : '연결 필요'}
|
||||
</span>
|
||||
{tailscaleStatus?.ip && <span className="text-[11px] text-[var(--text-muted)]">{tailscaleStatus.ip}</span>}
|
||||
</div>
|
||||
</SettingBlock>
|
||||
<SettingBlock title="계정" description="로그인된 계정">
|
||||
<span className="max-w-[220px] truncate text-right text-[12px] font-medium text-[var(--text-secondary)]">
|
||||
{tailscaleStatus?.loginName || tailscaleStatus?.userName || '-'}
|
||||
</span>
|
||||
</SettingBlock>
|
||||
<SettingBlock title="연결 다시 시도" description="연결 정보를 다시 확인">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTailscaleRetry?.()
|
||||
void loadTailscaleStatus()
|
||||
}}
|
||||
disabled={!onTailscaleRetry || tailscaleLoading}
|
||||
className={`rounded-md border border-[rgba(255,255,255,0.14)] px-3 py-2 text-[12px] font-semibold transition-colors ${
|
||||
onTailscaleRetry && !tailscaleLoading ? 'cursor-pointer text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white' : 'cursor-not-allowed text-white/35 opacity-60'
|
||||
}`}
|
||||
>
|
||||
다시 연결
|
||||
</button>
|
||||
</SettingBlock>
|
||||
<SettingBlock title="서비스 재시작" description="tailscaled 서비스 재시작" last>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void restartTailscaleService()}
|
||||
disabled={!window.launcherTailscale?.restartService || tailscaleRestarting}
|
||||
className={`rounded-md border border-[rgba(255,255,255,0.14)] px-3 py-2 text-[12px] font-semibold transition-colors ${
|
||||
window.launcherTailscale?.restartService && !tailscaleRestarting ? 'cursor-pointer text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white' : 'cursor-not-allowed text-white/35 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{tailscaleRestarting ? '재시작 중' : '서비스 재시작'}
|
||||
</button>
|
||||
{tailscaleMessage && <span className="max-w-[230px] text-right text-[11px] leading-4 text-[var(--text-muted)]">{tailscaleMessage}</span>}
|
||||
</div>
|
||||
</SettingBlock>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'info' && (
|
||||
<>
|
||||
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">정보</h3>
|
||||
@@ -1066,9 +1381,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>
|
||||
@@ -1147,6 +1462,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()
|
||||
|
||||
Vendored
+11
@@ -40,6 +40,10 @@ declare global {
|
||||
ok: boolean
|
||||
message: string
|
||||
}>
|
||||
ensureOperator: () => Promise<{
|
||||
ok: boolean
|
||||
message: string
|
||||
}>
|
||||
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
|
||||
ok: boolean
|
||||
message: string
|
||||
@@ -48,6 +52,10 @@ declare global {
|
||||
ok: boolean
|
||||
message: string
|
||||
}>
|
||||
restartService: () => Promise<{
|
||||
ok: boolean
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
launcherSshKey?: {
|
||||
ensure: () => Promise<{
|
||||
@@ -62,6 +70,9 @@ declare global {
|
||||
callback: (payload: { loginToken?: string; error?: string }) => void
|
||||
) => () => void
|
||||
}
|
||||
launcherSession?: {
|
||||
endUserSession: () => Promise<{ ok: boolean }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Tooltip from '../components/Tooltip'
|
||||
import AppView from './AppView'
|
||||
import type { LauncherApp } from '../config/apps'
|
||||
import type { AuthSession } from './LoginForm'
|
||||
import { refreshLauncherSession } from '../auth/sessionRefresh'
|
||||
|
||||
const version = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
@@ -12,12 +13,20 @@ 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(/\/+$/, '')
|
||||
const MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '')
|
||||
const LMS_WEB_BASE_URL = `${(
|
||||
import.meta.env.VITE_LMS_WEB_BASE_URL
|
||||
|| (import.meta.env.DEV ? 'http://localhost:5177' : 'https://workspace.autonomystudio.net/lms-web/')
|
||||
).replace(/\/+$/, '')}/`
|
||||
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
||||
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
||||
const TAILSCALE_DEVICE_ID_STORAGE_KEY = 'atnmy.launcher.tailscale-device-id'
|
||||
const IS_DEV = import.meta.env.DEV
|
||||
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
|
||||
|
||||
const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
||||
const normalizedAppKey = appKey.trim().toLowerCase()
|
||||
@@ -26,6 +35,19 @@ const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> => {
|
||||
let timer: number | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = window.setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout])
|
||||
} finally {
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
type AppNotice = {
|
||||
id: string
|
||||
date: string
|
||||
@@ -60,7 +82,72 @@ type AuthMeResponse = {
|
||||
user?: AuthSession['user']
|
||||
}
|
||||
|
||||
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
|
||||
type TailscaleProvisionResponse = {
|
||||
tailscaleAuthKey?: string
|
||||
tailscaleLoginServer?: string
|
||||
tailscaleHostname?: string
|
||||
}
|
||||
|
||||
function normalizeHeadscaleAccount(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/@/g, '-at-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount: string): boolean {
|
||||
if (!statusAccount) return false
|
||||
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||
}
|
||||
|
||||
function getTailscaleDeviceId(): string {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(TAILSCALE_DEVICE_ID_STORAGE_KEY)?.trim()
|
||||
if (stored && /^[a-z0-9-]{6,24}$/i.test(stored)) return stored
|
||||
|
||||
const next = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID().replace(/-/g, '').slice(0, 10)
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
||||
window.localStorage.setItem(TAILSCALE_DEVICE_ID_STORAGE_KEY, next)
|
||||
return next
|
||||
} catch {
|
||||
return 'launcher-client'
|
||||
}
|
||||
}
|
||||
|
||||
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
||||
return Boolean(
|
||||
status?.backendState === 'Unavailable' &&
|
||||
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? ''),
|
||||
)
|
||||
}
|
||||
|
||||
function extractProvisionErrorMessage(payload: unknown): string {
|
||||
if (!payload) return ''
|
||||
if (typeof payload === 'string') return payload
|
||||
if (Array.isArray(payload)) return payload.map(extractProvisionErrorMessage).filter(Boolean).join(', ')
|
||||
if (typeof payload !== 'object') return ''
|
||||
|
||||
const record = payload as Record<string, unknown>
|
||||
const candidates = [record.message, record.details, record.error]
|
||||
for (const candidate of candidates) {
|
||||
const message = extractProvisionErrorMessage(candidate)
|
||||
if (message && message !== '[object Object]' && !/^(bad gateway|internal server error|unauthorized|forbidden)$/i.test(message)) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: DashboardProps): React.JSX.Element {
|
||||
const [apps, setApps] = useState<LauncherApp[]>([])
|
||||
const [activeApp, setActiveApp] = useState('')
|
||||
const [appNotices, setAppNotices] = useState<AppNotice[]>([])
|
||||
@@ -86,9 +173,16 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
const [appActionMenuOpen, setAppActionMenuOpen] = useState(false)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [deletingApps, setDeletingApps] = useState<Record<string, boolean>>({})
|
||||
const [tailscaleIssue, setTailscaleIssue] = useState<{ state?: 'checking' | 'warning'; title: string; message: string } | null>(null)
|
||||
const [tailscaleRetryNonce, setTailscaleRetryNonce] = useState(0)
|
||||
const noticeRef = useRef<HTMLDivElement>(null)
|
||||
const appActionMenuRef = useRef<HTMLDivElement>(null)
|
||||
const initialAppSelectionDoneRef = useRef(false)
|
||||
const authSessionRef = useRef(authSession)
|
||||
|
||||
useEffect(() => {
|
||||
authSessionRef.current = authSession
|
||||
}, [authSession])
|
||||
|
||||
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
||||
const hasNotices = appNotices.length > 0
|
||||
@@ -117,16 +211,15 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
: null
|
||||
const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp
|
||||
const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const session = await response.json() as AuthSession
|
||||
onSessionUpdate(session)
|
||||
return session
|
||||
try {
|
||||
const session = await refreshLauncherSession()
|
||||
if (!session) return null
|
||||
onSessionUpdate(session)
|
||||
return session
|
||||
} catch (error) {
|
||||
console.warn('[launcher] session refresh temporarily failed', error)
|
||||
return authSessionRef.current
|
||||
}
|
||||
}, [onSessionUpdate])
|
||||
|
||||
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
@@ -144,13 +237,145 @@ 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])
|
||||
const fetchWithAuthRefreshRef = useRef(fetchWithAuthRefresh)
|
||||
fetchWithAuthRefreshRef.current = fetchWithAuthRefresh
|
||||
|
||||
useEffect(() => {
|
||||
const launcherTailscale = window.launcherTailscale
|
||||
if (!launcherTailscale) return
|
||||
const userEmail = authSession.user.email.trim().toLowerCase()
|
||||
if (!userEmail) return
|
||||
let cancelled = false
|
||||
|
||||
const connectTailscaleInBackground = async (): Promise<void> => {
|
||||
try {
|
||||
setTailscaleIssue({
|
||||
state: 'checking',
|
||||
title: '원격 연결 확인 중',
|
||||
message: '연결 상태를 확인하고 있습니다.',
|
||||
})
|
||||
await withTimeout((async () => {
|
||||
const status = await launcherTailscale.status().catch(() => null)
|
||||
if (cancelled) return
|
||||
if (isTailscaleMissingStatus(status)) {
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 구성 요소 설치 필요',
|
||||
message: '플랫폼 연결을 사용하려면 원격 연결 구성 요소 설치가 필요합니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const hasAccountIdentity = Boolean(
|
||||
status?.loginName?.trim() || status?.userName?.trim(),
|
||||
)
|
||||
const isSameAccount = Boolean(
|
||||
status &&
|
||||
(
|
||||
isSameTailscaleAccount(status.loginName, userEmail) ||
|
||||
isSameTailscaleAccount(status.userName, userEmail)
|
||||
),
|
||||
)
|
||||
if (status?.connected && (!hasAccountIdentity || isSameAccount)) {
|
||||
setTailscaleIssue(null)
|
||||
return
|
||||
}
|
||||
|
||||
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
||||
if (cancelled) return
|
||||
|
||||
const response = await fetchWithAuthRefreshRef.current(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
hostname: window.navigator.userAgent.includes('Electron')
|
||||
? `launcher-client-${getTailscaleDeviceId()}`
|
||||
: `client-${getTailscaleDeviceId()}`,
|
||||
usageType: 'launcher-client',
|
||||
publicSshKey: sshKey?.publicKey,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as unknown
|
||||
throw new Error(extractProvisionErrorMessage(payload) || 'Tailscale 연결 정보를 가져오지 못했습니다.')
|
||||
}
|
||||
|
||||
const provision = await response.json() as TailscaleProvisionResponse
|
||||
if (!provision.tailscaleAuthKey) {
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 정보 없음',
|
||||
message: '플랫폼 연결 정보를 가져오지 못했습니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const shouldResetSession = Boolean(
|
||||
status &&
|
||||
status.backendState !== 'Unavailable' &&
|
||||
hasAccountIdentity &&
|
||||
!isSameAccount,
|
||||
)
|
||||
const result = await launcherTailscale.login(
|
||||
provision.tailscaleAuthKey,
|
||||
provision.tailscaleLoginServer ?? '',
|
||||
provision.tailscaleHostname,
|
||||
shouldResetSession,
|
||||
)
|
||||
if (!result.ok) {
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 확인 필요',
|
||||
message: result.message || '원격 연결에 실패했습니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextStatus = await launcherTailscale.status().catch(() => null)
|
||||
const nextSameAccount = Boolean(
|
||||
nextStatus &&
|
||||
(
|
||||
isSameTailscaleAccount(nextStatus.loginName, userEmail) ||
|
||||
isSameTailscaleAccount(nextStatus.userName, userEmail)
|
||||
),
|
||||
)
|
||||
|
||||
if (nextStatus?.connected && nextSameAccount) {
|
||||
setTailscaleIssue(null)
|
||||
} else {
|
||||
const detail = nextStatus?.error || nextStatus?.health?.[0]
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 확인 필요',
|
||||
message: detail || '현재 로그인 계정으로 원격 연결이 정상 처리되지 않았습니다.',
|
||||
})
|
||||
}
|
||||
})(), TAILSCALE_BACKGROUND_TIMEOUT_MS, '원격 연결 확인 시간이 초과되었습니다.')
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
setTailscaleIssue({
|
||||
state: 'warning',
|
||||
title: '원격 연결 확인 필요',
|
||||
message: error instanceof Error ? error.message : '원격 연결에 실패했습니다.',
|
||||
})
|
||||
console.warn('[launcher] background tailscale login failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
void connectTailscaleInBackground()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authSession.user.email, tailscaleRetryNonce])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -167,7 +392,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshSession()
|
||||
if (!refreshed) {
|
||||
if (!cancelled) onLogout()
|
||||
if (!cancelled) onSessionExpired()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,7 +411,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
|
||||
@@ -202,7 +430,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authSession, onLogout, onSessionUpdate, refreshSession])
|
||||
}, [authSession, onSessionExpired, onSessionUpdate, refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -213,11 +441,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
if (cancelled || !payload?.apps?.length) return
|
||||
setApps(payload.apps)
|
||||
const currentApp = payload.apps.find((app) => app.appKey === activeApp)
|
||||
const currentAppAvailable = currentApp?.canLaunch !== false && currentApp?.canView !== false
|
||||
const firstAvailableApp = payload.apps.find((app) => app.canView !== false && app.canLaunch !== false)
|
||||
const currentAppAvailable = currentApp?.canView !== false
|
||||
const firstAvailableApp = payload.apps.find((app) => app.canView !== false)
|
||||
const lastLaunchedAppKey = window.localStorage.getItem(LAST_LAUNCHED_APP_STORAGE_KEY)
|
||||
const lastLaunchedApp = lastLaunchedAppKey
|
||||
? payload.apps.find((app) => app.appKey === lastLaunchedAppKey && app.canView !== false && app.canLaunch !== false)
|
||||
? payload.apps.find((app) => app.appKey === lastLaunchedAppKey && app.canView !== false)
|
||||
: undefined
|
||||
|
||||
if (!initialAppSelectionDoneRef.current) {
|
||||
@@ -286,8 +514,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
installation = { installed: false, version: null, path: null }
|
||||
}
|
||||
|
||||
if (IS_DEV && appConfig.devAppPath && installation.installed) {
|
||||
if (IS_DEV && appConfig.devAppPath) {
|
||||
if (!cancelled) {
|
||||
setInstalledOverrides((prev) => prev[activeApp] === undefined ? prev : { ...prev, [activeApp]: true })
|
||||
setAppStatus({
|
||||
appKey: activeApp,
|
||||
displayName: appConfig.displayName,
|
||||
@@ -529,18 +758,12 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
let response = await requestHandoff(authSession.accessToken)
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshResponse = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (refreshResponse.ok) {
|
||||
const nextSession = await refreshResponse.json() as AuthSession
|
||||
const nextSession = await refreshLauncherSession()
|
||||
if (nextSession) {
|
||||
onSessionUpdate(nextSession)
|
||||
response = await requestHandoff(nextSession.accessToken)
|
||||
} else {
|
||||
alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
|
||||
onLogout()
|
||||
onSessionExpired()
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -554,7 +777,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
|
||||
@@ -564,6 +787,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
|
||||
let handoffCode: string | undefined
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('prepare-app-runtime', {
|
||||
appName: activeApp,
|
||||
execPath: appConfig.execPath,
|
||||
devAppPath: appConfig.devAppPath,
|
||||
})
|
||||
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
||||
if (handoffAudienceApp) {
|
||||
const [handoffToken] = await Promise.all([
|
||||
@@ -589,11 +817,39 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
})
|
||||
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
||||
setRunningApps((prev) => new Set([...prev, activeApp]))
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '앱 실행 준비에 실패했습니다.')
|
||||
} finally {
|
||||
setLaunchingApp(null)
|
||||
}
|
||||
}, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp])
|
||||
|
||||
const handleWebLaunch = useCallback(async () => {
|
||||
if (activeApp.trim().toLowerCase() !== 'lms' || launchingApp) return
|
||||
|
||||
setAppActionMenuOpen(false)
|
||||
setLaunchingApp(activeApp)
|
||||
try {
|
||||
const handoffToken = await createHandoffToken('LMS')
|
||||
if (!handoffToken) return
|
||||
|
||||
const launchUrl = new URL(LMS_WEB_BASE_URL)
|
||||
launchUrl.hash = new URLSearchParams({
|
||||
handoffToken,
|
||||
audienceApp: 'LMS',
|
||||
}).toString()
|
||||
await window.electron.ipcRenderer.invoke('launch-web-app', {
|
||||
appName: activeApp,
|
||||
url: launchUrl.toString(),
|
||||
})
|
||||
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
||||
} catch (error) {
|
||||
alert(`웹 실행에 실패했습니다. (${error instanceof Error ? error.message : '알 수 없는 오류'})`)
|
||||
} finally {
|
||||
setLaunchingApp(null)
|
||||
}
|
||||
}, [activeApp, createHandoffToken, launchingApp])
|
||||
|
||||
const handleFocusApp = useCallback(() => {
|
||||
window.electron.ipcRenderer.send('focus-app-window', activeApp)
|
||||
}, [activeApp])
|
||||
@@ -652,6 +908,9 @@ 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)}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
@@ -688,7 +947,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
<span className="notice-dot" />
|
||||
<span className="notice-label">공지사항</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--text-muted)]">{appNotices.length}건</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{appNotices.map((n, i) => (
|
||||
@@ -747,6 +1005,9 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
) : canLaunch ? (
|
||||
activeAppLaunching ? (
|
||||
<button className="launch-pending-btn" disabled>
|
||||
<svg aria-hidden="true" className="spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
|
||||
<path d="M12 2a10 10 0 1 0 10 10" />
|
||||
</svg>
|
||||
실행중
|
||||
</button>
|
||||
) : (
|
||||
@@ -774,7 +1035,17 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
</div>
|
||||
{appActionMenuOpen && (
|
||||
<div className="launch-action-dropdown">
|
||||
<button type="button" onClick={openDeleteConfirm} disabled={activeAppDeleting}>
|
||||
{activeApp.trim().toLowerCase() === 'lms' && (
|
||||
<button type="button" onClick={handleWebLaunch}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
</svg>
|
||||
<span>웹 실행</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="launch-action-danger" onClick={openDeleteConfirm} disabled={activeAppDeleting}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
@@ -805,7 +1076,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
<div className="absolute top-0 left-0 right-0 h-[48px] flex items-center px-4 gap-3 border-b border-[var(--border)] bg-[var(--bg-sidebar)] z-10">
|
||||
<button
|
||||
onClick={() => setShowManual(false)}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
@@ -814,7 +1085,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps):
|
||||
<span className="text-[13px] font-medium text-[var(--text-secondary)]">사용 매뉴얼</span>
|
||||
</div>
|
||||
<iframe
|
||||
src="appdocs://host/install.html"
|
||||
src={`${MANUAL_BASE_URL}/?appKey=${encodeURIComponent(activeApp)}`}
|
||||
className="absolute left-0 right-0 bottom-0 border-none"
|
||||
style={{ top: '48px', width: '100%', height: 'calc(100% - 48px)', background: '#0a0b0e' }}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,8 @@ function Login({ onLogin }: LoginProps): React.JSX.Element {
|
||||
const [updateProgress, setUpdateProgress] = useState(0)
|
||||
const [typedCount, setTypedCount] = useState(0)
|
||||
const [loginLoading, setLoginLoading] = useState(false)
|
||||
const [loginEmail, setLoginEmail] = useState('')
|
||||
const [loginNotice, setLoginNotice] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -258,10 +260,19 @@ function Login({ onLogin }: LoginProps): React.JSX.Element {
|
||||
onGoSignup={() => setView('signup')}
|
||||
onGoPasswordReset={() => setView('password-reset')}
|
||||
onLoadingChange={setLoginLoading}
|
||||
initialEmail={loginEmail}
|
||||
noticeMessage={loginNotice}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[420px] flex items-center justify-center">
|
||||
<SignupForm onGoLogin={() => setView('login')} onSignupComplete={onLogin} />
|
||||
<SignupForm
|
||||
onGoLogin={() => setView('login')}
|
||||
onSignupComplete={(email) => {
|
||||
setLoginEmail(email)
|
||||
setLoginNotice('회원가입이 완료되었습니다. 비밀번호를 입력해 로그인해주세요.')
|
||||
setView('login')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[420px] flex items-center justify-center">
|
||||
<PasswordResetForm onGoLogin={() => setView('login')} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||
|
||||
interface LoginFormProps {
|
||||
@@ -7,6 +6,8 @@ interface LoginFormProps {
|
||||
onGoSignup: () => void
|
||||
onGoPasswordReset: () => void
|
||||
onLoadingChange: (v: boolean) => void
|
||||
initialEmail?: string
|
||||
noticeMessage?: string
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
@@ -19,6 +20,7 @@ export interface AuthSession {
|
||||
email: string
|
||||
name: string | null
|
||||
role: string
|
||||
loginMethod?: 'GOOGLE' | 'PASSWORD'
|
||||
roles?: string[]
|
||||
createdAt?: string
|
||||
lastLoginAt?: string | null
|
||||
@@ -44,78 +46,12 @@ const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || '
|
||||
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 MIN_LOGIN_DURATION_MS = 2000
|
||||
const GOOGLE_LOGIN_WAIT_TIMEOUT_MS = 30_000
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
const LAST_LOGIN_EMAIL_KEY = 'atnmy.auth.last-login-email'
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
|
||||
type TailscaleProvisionResponse = {
|
||||
installTailscale?: boolean
|
||||
tailscaleAuthKey?: string
|
||||
tailscaleLoginServer?: string
|
||||
tailscaleHostname?: string
|
||||
}
|
||||
|
||||
type PendingTailscaleConfirm = {
|
||||
session: AuthSession
|
||||
email: string
|
||||
password: string
|
||||
keepLogin: boolean
|
||||
message: string
|
||||
reason: 'install-required' | 'connection-failed'
|
||||
}
|
||||
|
||||
class TailscaleInstallRequiredError extends Error {
|
||||
constructor() {
|
||||
super('원격 연결 구성 요소 설치가 필요합니다.')
|
||||
this.name = 'TailscaleInstallRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
||||
return Boolean(
|
||||
status?.backendState === 'Unavailable' &&
|
||||
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function extractProvisionErrorMessage(payload: unknown): string {
|
||||
if (!payload) return ''
|
||||
if (typeof payload === 'string') return payload
|
||||
if (Array.isArray(payload)) return payload.map(extractProvisionErrorMessage).filter(Boolean).join(', ')
|
||||
if (typeof payload !== 'object') return ''
|
||||
|
||||
const record = payload as Record<string, unknown>
|
||||
const candidates = [record.message, record.details, record.error]
|
||||
for (const candidate of candidates) {
|
||||
const message = extractProvisionErrorMessage(candidate)
|
||||
if (message && message !== '[object Object]' && !/^(bad gateway|internal server error|unauthorized|forbidden)$/i.test(message)) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeadscaleAccount(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/@/g, '-at-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount: string): boolean {
|
||||
if (!statusAccount) return false
|
||||
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||
}
|
||||
|
||||
function readLastLoginEmail(): string {
|
||||
try {
|
||||
return window.localStorage.getItem(LAST_LOGIN_EMAIL_KEY) ?? ''
|
||||
@@ -132,76 +68,28 @@ function storeLastLoginEmail(email: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLauncherTailscale(account: string, password: string): Promise<void> {
|
||||
if (!window.launcherTailscale) return
|
||||
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
||||
|
||||
const status = await window.launcherTailscale.status().catch(() => null)
|
||||
const isSameAccount = Boolean(
|
||||
status &&
|
||||
(
|
||||
isSameTailscaleAccount(status.loginName, account) ||
|
||||
isSameTailscaleAccount(status.userName, account)
|
||||
)
|
||||
)
|
||||
if (
|
||||
status?.connected &&
|
||||
isSameAccount
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (isTailscaleMissingStatus(status)) {
|
||||
throw new TailscaleInstallRequiredError()
|
||||
}
|
||||
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
|
||||
usageType: 'launcher-client',
|
||||
publicSshKey: sshKey?.publicKey,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as unknown
|
||||
const message = extractProvisionErrorMessage(payload)
|
||||
throw new Error(message ? `Tailscale 연결 정보를 가져오지 못했습니다: ${message}` : 'Tailscale 연결 정보를 가져오지 못했습니다.')
|
||||
}
|
||||
|
||||
const provision = await response.json() as TailscaleProvisionResponse
|
||||
if (!provision.tailscaleAuthKey) {
|
||||
throw new Error('Tailscale 인증키가 없습니다.')
|
||||
}
|
||||
|
||||
const result = await window.launcherTailscale.login(
|
||||
provision.tailscaleAuthKey,
|
||||
provision.tailscaleLoginServer ?? '',
|
||||
provision.tailscaleHostname,
|
||||
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
||||
)
|
||||
if (!result.ok) {
|
||||
const nextStatus = await window.launcherTailscale.status().catch(() => null)
|
||||
if (nextStatus?.connected) return
|
||||
throw new Error(result.message || 'Tailscale 연결에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }: LoginFormProps): React.JSX.Element {
|
||||
const [email, setEmail] = useState(() => readLastLoginEmail())
|
||||
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, initialEmail, noticeMessage }: LoginFormProps): React.JSX.Element {
|
||||
const [email, setEmail] = useState(() => initialEmail || readLastLoginEmail())
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [keepLogin, setKeepLogin] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
|
||||
const [tailscaleInstalling, setTailscaleInstalling] = useState(false)
|
||||
const googleLoginTimeoutRef = useRef<number | null>(null)
|
||||
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
|
||||
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
|
||||
const clearGoogleLoginTimeout = (): void => {
|
||||
if (!googleLoginTimeoutRef.current) return
|
||||
window.clearTimeout(googleLoginTimeoutRef.current)
|
||||
googleLoginTimeoutRef.current = null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initialEmail) setEmail(initialEmail)
|
||||
}, [initialEmail])
|
||||
|
||||
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))
|
||||
@@ -219,7 +107,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.launcherAuth?.onGoogleOAuthCallback(async (payload) => {
|
||||
setPendingTailscaleConfirm(null)
|
||||
clearGoogleLoginTimeout()
|
||||
setError('')
|
||||
|
||||
if (payload.error || !payload.loginToken) {
|
||||
@@ -243,7 +131,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
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 {
|
||||
@@ -252,16 +140,26 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
return () => {
|
||||
clearGoogleLoginTimeout()
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [completeLogin, keepLogin, onLoadingChange])
|
||||
|
||||
const handleGoogleLogin = async (): Promise<void> => {
|
||||
clearGoogleLoginTimeout()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
onLoadingChange(true)
|
||||
|
||||
try {
|
||||
await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`)
|
||||
googleLoginTimeoutRef.current = window.setTimeout(() => {
|
||||
googleLoginTimeoutRef.current = null
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
setError('Google 로그인 응답이 없어 대기를 종료했습니다. 브라우저에서 로그인을 취소했다면 다시 시도해주세요.')
|
||||
}, GOOGLE_LOGIN_WAIT_TIMEOUT_MS)
|
||||
} catch {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
@@ -274,7 +172,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
if (emailError) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setPendingTailscaleConfirm(null)
|
||||
onLoadingChange(true)
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
@@ -285,25 +182,13 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
throw new Error('로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해주세요.')
|
||||
}
|
||||
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
||||
}
|
||||
const session = await response.json() as AuthSession
|
||||
try {
|
||||
await ensureLauncherTailscale(email, password)
|
||||
} catch (tailscaleError) {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
setPendingTailscaleConfirm({
|
||||
session,
|
||||
email,
|
||||
password,
|
||||
keepLogin,
|
||||
reason: tailscaleError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
|
||||
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
await completeLogin(session, email, keepLogin, startedAt)
|
||||
await completeLogin(session, email, keepLogin, startedAt, 'PASSWORD')
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
|
||||
} finally {
|
||||
@@ -312,63 +197,17 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
}
|
||||
}
|
||||
|
||||
const continueWithoutTailscale = async (): Promise<void> => {
|
||||
if (!pendingTailscaleConfirm) return
|
||||
const pending = pendingTailscaleConfirm
|
||||
setPendingTailscaleConfirm(null)
|
||||
setLoading(true)
|
||||
setError('')
|
||||
onLoadingChange(true)
|
||||
try {
|
||||
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||
} catch (continueError) {
|
||||
setError(continueError instanceof Error ? continueError.message : '로그인에 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const installTailscaleAndContinue = async (): Promise<void> => {
|
||||
if (!pendingTailscaleConfirm || !window.launcherTailscale?.install) return
|
||||
const pending = pendingTailscaleConfirm
|
||||
setTailscaleInstalling(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const installResult = await window.launcherTailscale.install()
|
||||
if (!installResult.ok) {
|
||||
setPendingTailscaleConfirm({
|
||||
...pending,
|
||||
message: installResult.message || 'Tailscale 설치에 실패했습니다.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ensureLauncherTailscale(pending.email, pending.password)
|
||||
setPendingTailscaleConfirm(null)
|
||||
setLoading(true)
|
||||
onLoadingChange(true)
|
||||
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||
} catch (installError) {
|
||||
setPendingTailscaleConfirm({
|
||||
...pending,
|
||||
reason: installError instanceof TailscaleInstallRequiredError ? 'install-required' : 'connection-failed',
|
||||
message: installError instanceof Error ? installError.message : 'Tailscale 설치 또는 연결에 실패했습니다.',
|
||||
})
|
||||
} finally {
|
||||
setTailscaleInstalling(false)
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
|
||||
|
||||
<h2 className="text-[20px] font-bold text-[var(--text-primary)] mb-1">로그인</h2>
|
||||
<p className="text-[12px] text-[var(--text-muted)] mb-6">계정에 로그인하여 서비스를 이용하세요.</p>
|
||||
{noticeMessage && (
|
||||
<div className="mb-4 rounded-lg border border-[rgba(59,200,130,0.25)] bg-[rgba(59,200,130,0.08)] px-3 py-2 text-[12px] font-medium leading-relaxed text-[rgba(120,235,175,0.92)]">
|
||||
{noticeMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div>
|
||||
@@ -442,56 +281,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }:
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{pendingTailscaleConfirm && createPortal(
|
||||
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/45 px-6 backdrop-blur-[2px]">
|
||||
<section className="w-full max-w-[400px] overflow-hidden rounded-xl border border-white/14 bg-[#121722] shadow-[0_28px_80px_rgba(0,0,0,0.45)]">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<h3 className="text-[15px] font-bold text-white">
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? '원격 연결 구성 요소 설치' : 'Tailscale 연결 실패'}
|
||||
</h3>
|
||||
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
|
||||
{pendingTailscaleConfirm.reason === 'install-required'
|
||||
? 'Autonomy Studio는 플랫폼 연결을 위해 Tailscale을 사용합니다. 설치를 진행하려면 관리자 권한이 필요합니다.'
|
||||
: '플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="max-h-[96px] overflow-y-auto rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-medium leading-relaxed text-white/56">
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? 'Tailscale이 설치되어 있지 않습니다.' : pendingTailscaleConfirm.message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continueWithoutTailscale()}
|
||||
className="h-9 cursor-pointer rounded-md border border-white/16 bg-white/6 px-4 text-[12px] font-bold text-white/72 transition hover:bg-white/10 hover:text-white"
|
||||
disabled={tailscaleInstalling}
|
||||
>
|
||||
나중에
|
||||
</button>
|
||||
{pendingTailscaleConfirm.reason === 'install-required' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installTailscaleAndContinue()}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-60"
|
||||
disabled={tailscaleInstalling}
|
||||
>
|
||||
{tailscaleInstalling ? '설치 중...' : '설치'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continueWithoutTailscale()}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea]"
|
||||
>
|
||||
계속 진행
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { EnvelopeIcon, LockIcon, PersonIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||
import type { AuthSession } from './LoginForm'
|
||||
|
||||
interface SignupFormProps {
|
||||
onGoLogin: () => void
|
||||
onSignupComplete: (session: AuthSession) => void
|
||||
onSignupComplete: (email: string) => void
|
||||
}
|
||||
|
||||
const AUTH_BASE_URL = (
|
||||
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
||||
).replace(/\/+$/, '')
|
||||
const AUTH_API_BASE_URL = AUTH_BASE_URL
|
||||
const LAUNCHER_API_BASE_URL = (
|
||||
import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200'
|
||||
).replace(/\/+$/, '')
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
|
||||
const termLabels: Record<string, string> = {
|
||||
terms: '이용약관',
|
||||
privacy: '개인정보 처리방침',
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = []
|
||||
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)]+)\)|\*\*([^*]+)\*\*)/g
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index))
|
||||
}
|
||||
|
||||
if (match[2] && match[3]) {
|
||||
nodes.push(
|
||||
<a
|
||||
key={`link-${match.index}`}
|
||||
href={match[3]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline"
|
||||
>
|
||||
{match[2]}
|
||||
</a>,
|
||||
)
|
||||
} else if (match[4]) {
|
||||
nodes.push(
|
||||
<strong key={`strong-${match.index}`} className="font-semibold text-[var(--text-primary)]">
|
||||
{match[4]}
|
||||
</strong>,
|
||||
)
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): ReactNode[] {
|
||||
const blocks: ReactNode[] = []
|
||||
const lines = markdown.split(/\r?\n/)
|
||||
let listItems: ReactNode[][] = []
|
||||
let listType: 'ul' | 'ol' | null = null
|
||||
|
||||
const flushList = (): void => {
|
||||
if (!listType || listItems.length === 0) return
|
||||
const Tag = listType
|
||||
blocks.push(
|
||||
<Tag
|
||||
key={`list-${blocks.length}`}
|
||||
className={`${listType === 'ul' ? 'list-disc' : 'list-decimal'} my-3 space-y-1 pl-5 text-[13px] leading-6 text-[var(--text-secondary)]`}
|
||||
>
|
||||
{listItems.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</Tag>,
|
||||
)
|
||||
listItems = []
|
||||
listType = null
|
||||
}
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
flushList()
|
||||
return
|
||||
}
|
||||
|
||||
if (/^[-*_]{3,}$/.test(trimmed)) {
|
||||
flushList()
|
||||
blocks.push(
|
||||
<hr
|
||||
key={`hr-${blocks.length}`}
|
||||
className="my-5 border-0 border-t border-[rgba(255,255,255,0.12)]"
|
||||
/>,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/)
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/)
|
||||
if (unorderedMatch || orderedMatch) {
|
||||
const nextType = unorderedMatch ? 'ul' : 'ol'
|
||||
if (listType && listType !== nextType) {
|
||||
flushList()
|
||||
}
|
||||
listType = nextType
|
||||
listItems.push(renderInlineMarkdown((unorderedMatch || orderedMatch)?.[1] || ''))
|
||||
return
|
||||
}
|
||||
|
||||
flushList()
|
||||
|
||||
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/)
|
||||
if (heading) {
|
||||
const level = heading[1].length
|
||||
const content = renderInlineMarkdown(heading[2])
|
||||
if (level === 1) {
|
||||
blocks.push(
|
||||
<h1 key={`h1-${blocks.length}`} className="mb-5 mt-1 text-center text-[18px] font-bold text-[var(--text-primary)]">
|
||||
{content}
|
||||
</h1>,
|
||||
)
|
||||
} else if (level === 2) {
|
||||
blocks.push(
|
||||
<h2 key={`h2-${blocks.length}`} className="mb-2 mt-5 text-[15px] font-semibold text-[var(--text-primary)]">
|
||||
{content}
|
||||
</h2>,
|
||||
)
|
||||
} else {
|
||||
blocks.push(
|
||||
<h3 key={`h3-${blocks.length}`} className="mb-2 mt-4 text-[14px] font-semibold text-[var(--text-primary)]">
|
||||
{content}
|
||||
</h3>,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
blocks.push(
|
||||
<p key={`p-${blocks.length}`} className="my-2 text-[13px] leading-6 text-[var(--text-secondary)]">
|
||||
{renderInlineMarkdown(trimmed)}
|
||||
</p>,
|
||||
)
|
||||
})
|
||||
|
||||
flushList()
|
||||
|
||||
return blocks.length > 0
|
||||
? blocks
|
||||
: [<p key="empty" className="text-[13px] text-[var(--text-muted)]">등록된 약관 내용이 없습니다.</p>]
|
||||
}
|
||||
|
||||
function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX.Element {
|
||||
const [name, setName] = useState('')
|
||||
@@ -30,6 +174,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
const [promoCode, setPromoCode] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [termModal, setTermModal] = useState<{ key: 'terms' | 'privacy'; title: string; body: string; loading: boolean } | null>(null)
|
||||
const emailError = signupEmail.length > 0 && !EMAIL_PATTERN.test(signupEmail) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
|
||||
|
||||
@@ -125,10 +270,8 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
throw new Error('회원가입에 실패했습니다.')
|
||||
}
|
||||
|
||||
const session = await response.json() as AuthSession
|
||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
onSignupComplete(session)
|
||||
await response.json().catch(() => null)
|
||||
onSignupComplete(normalizedSignupEmail)
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
|
||||
} finally {
|
||||
@@ -136,7 +279,65 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
}
|
||||
}
|
||||
|
||||
const openTermModal = async (key: 'terms' | 'privacy'): Promise<void> => {
|
||||
const title = termLabels[key]
|
||||
setTermModal({ key, title, body: '', loading: true })
|
||||
|
||||
try {
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/terms/${key}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('약관을 불러오지 못했습니다.')
|
||||
}
|
||||
|
||||
const payload = await response.json() as { term?: { body?: string } }
|
||||
setTermModal({ key, title, body: payload.term?.body || '등록된 약관 내용이 없습니다.', loading: false })
|
||||
} catch (termError) {
|
||||
setTermModal({
|
||||
key,
|
||||
title,
|
||||
body: termError instanceof Error ? termError.message : '약관을 불러오지 못했습니다.',
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleSignup = async (): Promise<void> => {
|
||||
setError('')
|
||||
try {
|
||||
await window.launcherAuth?.startGoogleLogin(`${AUTH_API_BASE_URL}/auth/google/login`)
|
||||
} catch {
|
||||
setError('Google 로그인 창을 열 수 없습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const termModalElement = termModal ? createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] px-4 backdrop-blur-[10px]">
|
||||
<div className="w-full max-w-[820px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.72)] 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="flex items-center justify-between border-b border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.035)] px-5 py-4">
|
||||
<h3 className="text-left text-[15px] font-semibold text-[var(--text-primary)]">약관 조회</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTermModal(null)}
|
||||
className="flex size-8 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-white/10 hover:text-[var(--text-primary)]"
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[58vh] overflow-y-auto px-5 py-4">
|
||||
{termModal.loading ? (
|
||||
<p className="text-[13px] text-[var(--text-muted)]">불러오는 중...</p>
|
||||
) : (
|
||||
<div className="break-words">{renderMarkdown(termModal.body)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -284,7 +485,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
이용약관에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||
<button type="button" onClick={() => void openTermModal('terms')} className="text-[11px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors ml-2 shrink-0 cursor-pointer">보기</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
@@ -293,7 +494,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
개인정보 처리방침에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||
<button type="button" onClick={() => void openTermModal('privacy')} className="text-[11px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors ml-2 shrink-0 cursor-pointer">보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -309,7 +510,7 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="login-google-btn w-full mb-5">
|
||||
<button type="button" onClick={handleGoogleSignup} className="login-google-btn w-full mb-5">
|
||||
<GoogleIcon />
|
||||
Google로 회원가입
|
||||
</button>
|
||||
@@ -321,6 +522,8 @@ function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{termModalElement}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"composite": false,
|
||||
"incremental": false,
|
||||
"noEmit": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["src/renderer/src/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"composite": false,
|
||||
"incremental": false,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
|
||||
Reference in New Issue
Block a user