init studio launcher frontend

This commit is contained in:
2026-07-02 11:20:40 +09:00
commit a294b724b4
93 changed files with 17158 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Autonomy Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/gh/moonspam/NanumSquare@1.0/nanumsquare.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from 'react'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AppWindowShell from './pages/AppWindowShell'
import type { AuthSession } from './pages/LoginForm'
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(/\/+$/, '')
function App(): React.JSX.Element {
const [authSession, setAuthSession] = useState<AuthSession | null>(() => {
try {
const raw = window.localStorage.getItem(AUTH_SESSION_KEY) ?? window.sessionStorage.getItem(AUTH_SESSION_KEY)
return raw ? JSON.parse(raw) as AuthSession : null
} catch {
return null
}
})
const meta = window.appWindowMeta
if (meta?.isAppWindow) {
return <AppWindowShell appName={meta.appName} url={meta.url} />
}
const handleSessionUpdate = useCallback((session: AuthSession): void => {
setAuthSession(session)
if (window.localStorage.getItem(AUTH_SESSION_KEY)) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) {
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
}
}, [])
const handleLogout = useCallback((): void => {
window.launcherTailscale?.logout().catch(() => undefined)
window.localStorage.removeItem(AUTH_SESSION_KEY)
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
setAuthSession(null)
}, [])
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)
.then((session) => {
if (session?.accessToken) {
handleSessionUpdate(session)
} else {
handleLogout()
}
})
.catch(() => {
handleLogout()
})
}, refreshDelayMs)
return () => window.clearTimeout(timer)
}, [authSession, handleLogout, handleSessionUpdate])
return authSession
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} />
: <Login onLogin={setAuthSession} />
}
export default App
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+933
View File
@@ -0,0 +1,933 @@
@import "tailwindcss";
/* @font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicLight.woff2') format('woff2');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothic.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicBold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
} */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
}
:root {
--bg-deep: #0a0b0e;
--bg-sidebar: #0d0f14;
--bg-main: #0f1117;
--border: rgba(255, 255, 255, 0.06);
--accent: #3b7ffa;
--accent-glow: rgba(59, 127, 250, 0.35);
--text-primary: #e8eaf0;
--text-secondary: rgba(220, 225, 238, 0.76);
--text-muted: rgba(157, 163, 175, 0.911);
--text-soft: #d6dae3;
--sidebar-width: 280px;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: #000;
font-family: 'NanumSquare', sans-serif;
}
/* ── Sidebar ── */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 8px 0;
flex-shrink: 0;
position: relative;
z-index: 20;
overflow: visible;
}
.sidebar::after {
content: '';
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 1px;
background: linear-gradient(
to bottom,
transparent,
rgba(59, 127, 250, 0.15) 40%,
rgba(59, 127, 250, 0.15) 60%,
transparent
);
pointer-events: none;
}
.nav-item {
display: flex;
align-items: center;
padding: 13px 24px;
font-size: 13.5px;
font-weight: 400;
color: var(--text-secondary);
cursor: pointer;
position: relative;
letter-spacing: 0.01em;
transition: color 0.2s, background 0.2s;
}
.nav-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
}
.nav-item-unavailable {
color: rgba(200, 205, 220, 0.42);
cursor: default;
}
.nav-item-unavailable .nav-item-icon {
opacity: 0.38;
}
.nav-item-unavailable:hover {
color: rgba(200, 205, 220, 0.5);
background: rgba(255, 255, 255, 0.015);
}
.nav-item-unavailable:hover .nav-item-icon {
opacity: 0.46;
}
.nav-item.active {
color: var(--accent);
font-weight: 500;
background: rgba(59, 127, 250, 0.07);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--accent);
border-radius: 0 3px 3px 0;
box-shadow: 0 0 12px var(--accent-glow);
}
.nav-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
margin-right: 10px;
opacity: 0.68;
flex-shrink: 0;
transition: opacity 0.2s;
}
.nav-item:hover .nav-item-icon {
opacity: 0.8;
}
.nav-item.active .nav-item-icon {
opacity: 1;
}
.nav-item-unavailable.active {
color: rgba(200, 205, 220, 0.54);
background: rgba(255, 255, 255, 0.025);
}
.nav-item-unavailable.active::before {
background: rgba(200, 205, 220, 0.24);
box-shadow: none;
}
.nav-item-unavailable.active .nav-item-icon {
opacity: 0.5;
}
.nav-item-collapsed {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
/* ── Sidebar Collapse Toggle Button ── */
.sidebar-collapse-btn {
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
z-index: 50;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-sidebar);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-muted);
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.sidebar-collapse-btn:hover {
color: var(--text-primary);
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.2);
}
/* ── Main Content ── */
.main-content {
flex: 1;
background: var(--bg-main);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-content::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20, 25, 40, 0.9) 0%, transparent 70%),
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15, 30, 60, 0.3) 0%, transparent 60%);
pointer-events: none;
}
/* ── Update Button ── */
.update-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
}
.update-btn:hover {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
transform: translateY(-1px);
}
.update-btn:active {
transform: translateY(0);
}
.update-btn:disabled {
background: rgba(59, 127, 250, 0.14);
border-color: rgba(59, 127, 250, 0.24);
color: rgba(255, 255, 255, 0.38);
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.update-btn:disabled:hover {
background: rgba(59, 127, 250, 0.14);
box-shadow: none;
transform: none;
}
/* ── Updating Button (in-progress state) ── */
.updating-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: rgba(59, 127, 250, 0.15);
border: 1px solid rgba(59, 127, 250, 0.3);
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: default;
}
/* ── Update Progress Bar ── */
.update-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.update-progress-track {
flex: 1;
height: 3px;
background: rgba(255,255,255,0.08);
border-radius: 99px;
overflow: hidden;
}
.update-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.2s ease;
box-shadow: 0 0 6px rgba(59,127,250,0.6);
}
.update-progress-pct {
font-size: 11px;
color: var(--text-muted);
min-width: 30px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Spinner ── */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spin {
animation: spin 0.8s linear infinite;
}
/* ── Launch Button ── */
.launch-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
}
.launch-btn:hover {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
transform: translateY(-1px);
}
.launch-btn:active {
transform: translateY(0);
}
.launch-btn:disabled {
background: rgba(59, 127, 250, 0.14);
border-color: rgba(59, 127, 250, 0.24);
color: rgba(255, 255, 255, 0.38);
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.launch-btn:disabled:hover {
background: rgba(59, 127, 250, 0.14);
box-shadow: none;
transform: none;
}
.launch-pending-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 7px 16px;
min-width: 78px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: rgba(255, 255, 255, 0.42);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: not-allowed;
box-shadow: none;
}
/* ── Car Video ── */
.car-video {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center bottom;
opacity: 0;
animation: carFadeIn 1.4s 0.5s ease forwards;
z-index: 2;
}
.car-video-shadow {
position: absolute;
inset: 0;
background:
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15, 17, 23, 0.45) 32%, transparent 52%),
linear-gradient(to left, var(--bg-main) 0%, rgba(15, 17, 23, 0.6) 8%, transparent 35%),
linear-gradient(to top, rgba(5, 7, 12, 0.78) 0%, transparent 42%),
radial-gradient(ellipse 60% 60% at 100% 100%, rgba(5, 7, 12, 0.56) 0%, transparent 14%),
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5, 7, 12, 0.5) 100%);
pointer-events: none;
z-index: 3;
}
.floor-reflection {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 120px;
background: linear-gradient(to top, rgba(8, 10, 18, 0.8) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
}
/* ── Text Content ── */
.text-content {
position: relative;
z-index: 10;
max-width: 480px;
animation: textReveal 1s 0.2s ease both;
}
.brand-title {
font-family: 'Rajdhani', sans-serif;
font-size: 52px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.02em;
line-height: 1.0;
margin-bottom: 20px;
text-shadow: 0 0 60px rgba(59, 127, 250, 0.1);
}
.brand-title span {
color: var(--accent);
}
.subtitle {
font-size: 17px;
font-weight: 400;
color: var(--text-primary);
line-height: 1.7;
margin-bottom: 28px;
letter-spacing: 0.01em;
}
.divider {
width: 140px;
height: 2px;
background: linear-gradient(to right, var(--accent), transparent);
margin-bottom: 22px;
border-radius: 2px;
box-shadow: 0 0 10px var(--accent-glow);
}
.description {
font-size: 13.5px;
color: rgba(200, 210, 230, 0.85);
line-height: 2.0;
letter-spacing: 0.005em;
}
/* ── Verify Code Button ── */
.verify-code-btn {
flex-shrink: 0;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: 1px solid rgba(59, 127, 250, 0.35);
background: rgba(59, 127, 250, 0.10);
color: var(--accent);
white-space: nowrap;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.15s;
font-family: 'NanumGothic', sans-serif;
}
.verify-code-btn:hover:not(:disabled) {
background: rgba(59, 127, 250, 0.2);
border-color: rgba(59, 127, 250, 0.6);
box-shadow: 0 0 12px rgba(59, 127, 250, 0.25);
}
.verify-code-btn:disabled {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.07);
color: var(--text-muted);
cursor: not-allowed;
}
/* ── Notice Icon Button ── */
.notice-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.notice-icon-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
.notice-badge {
position: absolute;
top: 7px;
right: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
}
.notice-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 320px;
background: rgba(13, 15, 22, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.notice-item {
padding: 12px 16px;
}
.notice-item-border {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
/* ── Notice Panel ── */
.notice-panel {
padding: 14px 16px;
background: rgba(13, 15, 20, 0.75);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.notice-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
flex-shrink: 0;
}
.notice-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.notice-date {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.notice-title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 5px;
line-height: 1.4;
}
.notice-body {
font-size: 11.5px;
color: var(--text-secondary);
line-height: 1.7;
letter-spacing: 0.01em;
}
.notice-link-btn {
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font-family: 'NanumGothic', sans-serif;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.notice-link-btn:hover {
text-decoration: underline;
}
/* ── Dashboard ── */
.car-bg-radial {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25, 35, 70, 0.15) 0%, transparent 60%);
}
.version-label {
display: inline-flex;
align-items: center;
height: 16px;
font-size: 11px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.12em;
color: var(--text-soft);
}
.login-version-wrap {
position: absolute;
right: 24px;
bottom: 16px;
z-index: 5;
display: flex;
align-items: center;
gap: 10px;
}
.launcher-version-wrap {
position: absolute;
right: 24px;
bottom: 16px;
z-index: 5;
display: flex;
align-items: center;
gap: 10px;
}
.launcher-update-text {
display: inline-flex;
align-items: center;
position: relative;
top: -2px;
height: 16px;
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font-family: 'NanumGothic', sans-serif;
font-size: 11px;
font-weight: 600;
line-height: 16px;
cursor: pointer;
}
.launcher-update-text:hover {
text-decoration: underline;
color: #6fa4ff;
}
.launcher-update-text:disabled {
cursor: default;
opacity: 0.55;
text-decoration: none;
}
/* ── Login ── */
.login-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center bottom;
opacity: 0;
animation: carFadeIn 1.4s 0.3s ease forwards;
z-index: 0;
}
.login-video-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(to bottom, var(--bg-deep) 0%, rgba(10, 11, 14, 0.5) 35%, transparent 60%),
linear-gradient(to top, var(--bg-deep) 0%, transparent 40%),
linear-gradient(to right, rgba(10, 11, 14, 0.3) 0%, transparent 60%),
linear-gradient(to left, rgba(10, 11, 14, 0.5) 0%, transparent 55%),
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(10, 11, 14, 0.7) 0%, transparent 70%);
z-index: 1;
pointer-events: none;
}
.login-panel {
border-left: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(13, 15, 20, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.login-panel-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 1px;
background: linear-gradient(to bottom, transparent, rgba(59, 127, 250, 0.2) 30%, rgba(59, 127, 250, 0.2) 70%, transparent);
pointer-events: none;
}
.login-panel-glow {
position: absolute;
top: 0; left: 0; right: 0;
height: 200px;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(59, 127, 250, 0.06) 0%, transparent 100%);
pointer-events: none;
}
.animate-reveal {
animation: textReveal 0.8s 0.2s ease both;
}
.animate-reveal-late {
animation: textReveal 0.8s 0.3s ease both;
}
/* ── Login inputs ── */
.login-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.login-input-icon {
position: absolute;
left: 14px;
width: 15px;
height: 15px;
color: var(--text-muted);
pointer-events: none;
flex-shrink: 0;
}
.login-input-field {
width: 100%;
padding: 11px 16px 11px 40px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.login-input-field::placeholder { color: var(--text-muted); }
.login-input-field:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.profile-password-input {
width: 100%;
height: 36px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
caret-color: var(--text-primary);
}
.profile-password-input:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.login-input-eye {
position: absolute;
right: 12px;
width: 16px;
height: 16px;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.login-input-eye:hover { color: var(--text-secondary); }
.login-input-eye svg { width: 16px; height: 16px; }
.login-checkbox {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
.login-google-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 0;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.login-google-btn:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.12);
}
/* ── Login ── */
.login-input {
width: 100%;
padding: 11px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.login-input::placeholder {
color: var(--text-muted);
}
.login-input:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 11px 0;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.3);
}
.login-btn:hover:not(:disabled) {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.5);
transform: translateY(-1px);
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
}
/* ── Typing Cursor ── */
.typing-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--text-muted);
border-radius: 1px;
animation: cursorBlink 0.9s step-end infinite;
transform: translateY(2px);
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ── Running State ── */
.running-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 10px;
border-radius: 20px;
background: rgba(34, 197, 94, 0.07);
border: 1px solid rgba(34, 197, 94, 0.2);
margin-bottom: 24px;
}
.running-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5);
animation: running-pulse 1.8s ease infinite;
flex-shrink: 0;
}
@keyframes running-pulse {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
55% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
/* ── Animations ── */
@keyframes textReveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes carFadeIn {
from { opacity: 0; transform: translateX(30px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
+46
View File
@@ -0,0 +1,46 @@
export const EnvelopeIcon = (): React.JSX.Element => (
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
)
export const LockIcon = (): React.JSX.Element => (
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
)
export const PersonIcon = (): React.JSX.Element => (
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
)
export const EyeToggle = ({ show, onClick }: { show: boolean; onClick: () => void }): React.JSX.Element => (
<button type="button" onClick={onClick} className="login-input-eye">
{show ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
)
export const GoogleIcon = (): React.JSX.Element => (
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
)
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
import icon from '../assets/icon.png'
import { Minus, Square, X } from 'lucide-react'
import { useState } from 'react'
interface TitleBarProps {
appName?: string
}
function TitleBar({ appName }: TitleBarProps): React.JSX.Element {
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const handleMinimize = (): void => window.electron.ipcRenderer.send('window-minimize')
const handleMaximize = (): void => window.electron.ipcRenderer.send('window-maximize')
const handleClose = (): void => setShowCloseConfirm(true)
const handleConfirmClose = (): void => window.electron.ipcRenderer.send('window-close')
const winBtn =
'flex h-[20px] w-[20px] items-center justify-center text-white/50 hover:text-white transition cursor-pointer'
return (
<>
<div
className="flex h-[38px] shrink-0 items-center justify-between border-b border-white/10 bg-[#111520] px-4 pl-5 select-none"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* 좌측 */}
<div className="flex items-center">
<img
src={icon}
alt="logo"
className="mr-2 h-[18px] w-auto -translate-y-[2px] opacity-90"
/>
<span className="font-['Rajdhani',sans-serif] text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">
{appName ?? 'Autonomy Studio'}
</span>
</div>
{/* 우측 */}
<div
className="flex items-center gap-3"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<button onClick={handleMinimize} className={winBtn}>
<Minus size={12} strokeWidth={2.5} />
</button>
<button onClick={handleMaximize} className={winBtn}>
<Square size={11} strokeWidth={2.5} />
</button>
<button
onClick={handleClose}
className={`${winBtn} hover:text-red-400`}
>
<X size={12} strokeWidth={2.5} />
</button>
</div>
</div>
{showCloseConfirm && (
<div
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<div className="relative w-[320px] 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">
<header className="flex h-[54px] items-center justify-between px-5">
<h2 className="text-[15px] font-bold text-[var(--text-primary)]">Autonomy Studio</h2>
<button
type="button"
onClick={() => setShowCloseConfirm(false)}
className="grid h-8 w-8 place-items-center rounded-md text-white/55 hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
>
<X size={18} />
</button>
</header>
<div className="px-5 py-5">
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
앱을 종료하시겠습니까?
</p>
</div>
<footer className="flex h-[58px] items-center justify-end gap-2 px-5">
<button
type="button"
onClick={() => setShowCloseConfirm(false)}
className="rounded-md border border-[rgba(255,255,255,0.12)] px-5 py-2 text-[12px] font-semibold text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
>
취소
</button>
<button
type="button"
onClick={handleConfirmClose}
className="rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 hover:border-red-400/50 hover:bg-red-500/24 cursor-pointer transition-colors"
>
종료
</button>
</footer>
</div>
</div>
)}
</>
)
}
export default TitleBar
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
type TooltipProps = {
label: string
children: ReactNode
side?: 'top' | 'bottom'
}
function Tooltip({ label, children, side = 'bottom' }: TooltipProps): React.JSX.Element {
const sideClass = side === 'top'
? 'bottom-[calc(100%+8px)]'
: 'top-[calc(100%+8px)]'
const arrowClass = side === 'top'
? 'bottom-[-4px] border-b border-r'
: 'top-[-4px] border-l border-t'
return (
<span className="group/tooltip relative inline-flex">
{children}
<span
role="tooltip"
className={`pointer-events-none absolute right-0 ${sideClass} z-[9999] whitespace-nowrap rounded-md border border-[rgba(255,255,255,0.1)] bg-[#111827] px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white/85 opacity-0 shadow-[0_14px_40px_rgba(0,0,0,0.35)] transition duration-150 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100`}
>
{label}
<span className={`absolute right-3 h-2 w-2 rotate-45 border-[rgba(255,255,255,0.1)] bg-[#111827] ${arrowClass}`} />
</span>
</span>
)
}
export default Tooltip
+51
View File
@@ -0,0 +1,51 @@
export interface LauncherApp {
appKey: string
displayName: string
description: string
subtitle?: string | null
detailUrl?: string | null
videoPath?: string | null
iconKey: string
launchUrl?: string | null
devAppPath?: string | null
execPath?: string | null
sortOrder?: number
canView?: boolean
canLaunch?: boolean
}
export const FALLBACK_APPS: LauncherApp[] = [
{
appKey: 'ADS IDE',
displayName: 'ADS IDE',
description: '클라우드 기반에서 자율주행 소프트웨어를 설계·빌드·실행까지 빠르게 수행할 수 있는 통합 개발 플랫폼으로, 오픈소스 ADS 기반의 유연한 구조를 통해 기능 수정과 배포가 가능하며 다양한 실차 및 시뮬레이션 환경을 지원합니다.',
subtitle: '자율주행 모빌리티 소프트웨어\n통합 개발 환경 (IDE)',
detailUrl: 'https://www.futuredrive.net/solutions/astudio',
videoPath: './video/ads_ide.mp4',
iconKey: 'code',
launchUrl: 'http://localhost:5175',
devAppPath: './apps/ads-ide/ads-ide-fe',
execPath: './ADS-IDE-1.1.0.AppImage',
sortOrder: 10,
},
{
appKey: 'LMS',
displayName: 'LMS',
description: '자율주행 교육 과정, 실습 콘텐츠, 사용자 학습 이력을 통합 관리하는 학습 관리 시스템입니다.',
subtitle: '자율주행 교육 콘텐츠\n학습 관리 시스템',
videoPath: './video/lms.mp4',
iconKey: 'graduation',
launchUrl: 'http://localhost:5176',
devAppPath: './apps/lms/lms-fe',
execPath: './LMS-1.0.0.AppImage',
sortOrder: 20,
},
{ appKey: 'Data Management', displayName: 'Data Management', description: '주행 데이터 수집 · 저장 · 분석 플랫폼', videoPath: './video/data_management.mp4', iconKey: 'bar-chart', sortOrder: 30 },
{ appKey: 'HMI', displayName: 'HMI', description: '차량 인터페이스 모니터링 및 제어', videoPath: './video/hmi.mp4', iconKey: 'monitor', sortOrder: 40 },
{ appKey: 'FMS', displayName: 'FMS', description: '플릿 운영 및 차량 관제 시스템', videoPath: './video/fms.mp4', iconKey: 'truck', sortOrder: 50 },
{ appKey: 'ADS Education', displayName: 'ADS Education', description: '자율주행 기술 학습 및 교육 콘텐츠', videoPath: './video/ads_education.mp4', iconKey: 'graduation', sortOrder: 60 },
{ appKey: 'AI Benchmark', displayName: 'AI Benchmark', description: 'AI 모델 성능 평가 및 비교 분석', videoPath: './video/ai_benchmark.mp4', iconKey: 'activity', sortOrder: 70 },
{ appKey: 'Calibration', displayName: 'Calibration', description: '센서 캘리브레이션 및 설정 도구', videoPath: './video/calibration.mp4', iconKey: 'sliders', sortOrder: 80 },
{ appKey: 'IP Repository', displayName: 'IP Repository', description: '지식재산 및 소프트웨어 자산 저장소', videoPath: './video/ip_repository.mp4', iconKey: 'shopping-cart', sortOrder: 90 },
{ appKey: 'HDMAP Builder', displayName: 'HDMAP Builder', description: '고정밀 지도 생성 및 편집 도구', videoPath: './video/hdmap_builder.mp4', iconKey: 'map', sortOrder: 100 },
]
+66
View File
@@ -0,0 +1,66 @@
/// <reference types="vite/client" />
import type { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
appWindowMeta: {
isAppWindow: boolean
appName: string
url: string
}
launcherTailscale?: {
status: () => Promise<{
connected: boolean
ip: string
backendState: string
online?: boolean
health?: string[]
loginName?: string
userName?: string
peers?: Array<{
id: string
hostName: string
dnsName?: string
os?: string
userName?: string
loginName?: string
ip: string
ips: string[]
online: boolean
active: boolean
lastSeen?: string
relay?: string
currentAddress?: string
}>
error?: string
}>
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
ok: boolean
message: string
}>
logout: () => Promise<{
ok: boolean
message: string
}>
}
launcherSshKey?: {
ensure: () => Promise<{
publicKey: string
publicKeyPath: string
privateKeyPath: string
}>
}
}
}
interface ImportMetaEnv {
readonly VITE_APP_VERSION: string
readonly VITE_ATNMY_AUTH_API_BASE_URL?: string
readonly VITE_ATNMY_STDO_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './assets/main.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+133
View File
@@ -0,0 +1,133 @@
import type { LauncherApp } from '../config/apps'
const DEFAULT_DETAIL_URL = 'https://www.futuredrive.net/solutions/astudio'
function resolvePackagedAssetPath(path?: string | null): string | null {
if (!path) return null
if (/^(https?:|file:|data:|blob:)/i.test(path)) return path
if (path.startsWith('/video/')) return `.${path}`
return path
}
interface AppViewProps {
app: LauncherApp
isRunning: boolean
onShowManual: () => void
onFocusApp: () => void
onCloseApp: () => void
}
function AppView({ app, isRunning, onShowManual, onFocusApp, onCloseApp }: AppViewProps): React.JSX.Element {
const [titleFirst, titleRest] = app.displayName.includes(' ')
? [app.displayName.split(' ')[0], app.displayName.split(' ').slice(1).join(' ')]
: [app.displayName, null]
const appTitle = titleRest
? <>{titleFirst}<br /><span>{titleRest}</span></>
: <span>{titleFirst}</span>
const videoPath = resolvePackagedAssetPath(app.videoPath)
if (isRunning) {
return (
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_65%_55%_at_50%_45%,rgba(59,127,250,0.05)_0%,transparent_65%)]" />
<div className="text-content">
<h1 className="brand-title">
{appTitle}
</h1>
<div className="running-badge">
<span className="running-dot" />
<span className="text-[12px] font-medium tracking-[0.04em]" style={{ color: '#22c55e' }}>앱 실행 중</span>
</div>
<div className="divider" />
<p className="description" style={{ opacity: 0.55 }}>{app.description}</p>
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={onFocusApp}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
>
앱 창으로 이동
</button>
<button
type="button"
onClick={onCloseApp}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,80,80,0.2)] bg-[rgba(255,80,80,0.04)] text-[12px] hover:bg-[rgba(255,80,80,0.1)] hover:border-[rgba(255,80,80,0.35)] cursor-pointer transition-all duration-200"
style={{ color: 'rgba(255,110,110,0.75)' }}
>
앱 종료
</button>
</div>
</div>
</div>
)
}
return (
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
{/* 비디오 배경 */}
{videoPath && (
<>
<video
key={videoPath}
className="car-video"
src={videoPath}
autoPlay
loop
muted
playsInline
onError={(e) => { (e.target as HTMLVideoElement).style.display = 'none' }}
/>
<div className="car-video-shadow" />
<div className="floor-reflection" />
</>
)}
{!videoPath && (
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(59,127,250,0.04)_0%,transparent_70%)]" />
)}
{/* 텍스트 */}
<div className="text-content">
<h1 className="brand-title">
{appTitle}
</h1>
{app.subtitle && (
<p className="subtitle">
{app.subtitle.split('\n').map((line, i, arr) => (
<span key={i}>{line}{i < arr.length - 1 && <br />}</span>
))}
</p>
)}
<div className="divider" />
<p className="description">{app.description}</p>
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={() => {
window.open(app.detailUrl ?? DEFAULT_DETAIL_URL, '_blank', 'noopener,noreferrer')
}}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
>
자세히 보기
</button>
<button
type="button"
onClick={onShowManual}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.04)] text-[12px] text-[var(--text-secondary)] hover:bg-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.18)] cursor-pointer transition-all duration-200"
>
사용 매뉴얼
</button>
</div>
</div>
</div>
)
}
export default AppView
+20
View File
@@ -0,0 +1,20 @@
import TitleBar from '../components/TitleBar'
interface AppWindowShellProps {
appName: string
url: string
}
function AppWindowShell({ appName, url }: AppWindowShellProps): React.JSX.Element {
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-[#0a0b0e]">
<TitleBar appName={appName} />
<iframe
src={url}
style={{ flex: 1, border: 'none', display: 'block', background: '#0a0b0e' }}
/>
</div>
)
}
export default AppWindowShell
+758
View File
@@ -0,0 +1,758 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import TitleBar from '../components/TitleBar'
import Sidebar from '../components/Sidebar'
import Tooltip from '../components/Tooltip'
import AppView from './AppView'
import type { LauncherApp } from '../config/apps'
import type { AuthSession } from './LoginForm'
const version = import.meta.env.VITE_APP_VERSION
interface DashboardProps {
authSession: AuthSession
onSessionUpdate: (session: AuthSession) => void
onLogout: () => void
}
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
const IS_DEV = import.meta.env.DEV
const resolveHandoffAudienceApp = (appKey: string): string | null => {
const normalizedAppKey = appKey.trim().toLowerCase()
if (normalizedAppKey === 'lms') return 'LMS'
if (normalizedAppKey === 'ads ide' || normalizedAppKey === 'ads-ide') return 'ADS IDE'
return null
}
type AppNotice = {
id: string
date: string
title: string
body: string
linkUrl?: string | null
}
type AppUpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
type AppUpdateStatus = {
appKey: string
displayName?: string
installed: boolean
localVersion: string | null
latestVersion: string | null
status: 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP'
action: AppUpdateAction
updateRequired: boolean
downloadUrl?: string | null
releaseNotes?: string | null
canLaunch?: boolean
}
type InstallationInfo = {
installed: boolean
version: string | null
path: string | null
}
type AuthMeResponse = {
user?: AuthSession['user']
}
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
const [apps, setApps] = useState<LauncherApp[]>([])
const [activeApp, setActiveApp] = useState('')
const [appNotices, setAppNotices] = useState<AppNotice[]>([])
const [readNoticeSignatures, setReadNoticeSignatures] = useState<Record<string, string>>(() => {
try {
const raw = window.localStorage.getItem(NOTICE_READ_STORAGE_KEY)
return raw ? JSON.parse(raw) as Record<string, string> : {}
} catch {
return {}
}
})
const [appStatus, setAppStatus] = useState<AppUpdateStatus | null>(null)
const [launcherStatus, setLauncherStatus] = useState<AppUpdateStatus | null>(null)
const [noticeOpen, setNoticeOpen] = useState(false)
const [updatingApps, setUpdatingApps] = useState<Record<string, boolean>>({})
const [updateProgressByApp, setUpdateProgressByApp] = useState<Record<string, number>>({})
const [localVersions, setLocalVersions] = useState<Record<string, string>>({})
const [installedOverrides, setInstalledOverrides] = useState<Record<string, boolean>>({})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showManual, setShowManual] = useState(false)
const [runningApps, setRunningApps] = useState<Set<string>>(new Set())
const [launchingApp, setLaunchingApp] = useState<string | null>(null)
const noticeRef = useRef<HTMLDivElement>(null)
const initialAppSelectionDoneRef = useRef(false)
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
const hasNotices = appNotices.length > 0
const noticeSignature = appNotices.map((notice) => notice.id).join('|')
const hasUnreadNotices = hasNotices && readNoticeSignatures[activeApp] !== noticeSignature
const isRunning = runningApps.has(activeApp)
const activeLocalVersion = localVersions[activeApp]
const appAction = appStatus?.action ?? 'UNAVAILABLE'
const hasAppPermission = appConfig !== null && appConfig.canLaunch !== false && appStatus?.canLaunch !== false
const canLaunch = hasAppPermission && appAction === 'LAUNCH' && Boolean(appConfig?.launchUrl)
const hasAnyUpdatingApp = Object.values(updatingApps).some(Boolean)
const activeAppUpdating = Boolean(updatingApps[activeApp])
const activeAppLaunching = launchingApp === activeApp
const activeUpdateProgress = updateProgressByApp[activeApp] ?? 0
const launcherUpdateAvailable = launcherStatus?.action === 'UPDATE'
const launcherUpdateMessage = launcherUpdateAvailable
? `v${launcherStatus?.latestVersion ?? ''} 업데이트 가능`
: launcherStatus
? '최신버전'
: '업데이트 상태 확인중'
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
}, [onSessionUpdate])
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
const request = (accessToken: string): Promise<Response> => fetch(url, {
...options,
headers: {
...(options.headers ?? {}),
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
})
let response = await request(authSession.accessToken)
if (response.status !== 401) return response
const refreshed = await refreshSession()
if (!refreshed) {
onLogout()
return response
}
response = await request(refreshed.accessToken)
return response
}, [authSession.accessToken, onLogout, refreshSession])
useEffect(() => {
let cancelled = false
const loadUserProfile = async (): Promise<void> => {
let session = authSession
let response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
credentials: 'include',
})
if (response.status === 401) {
const refreshed = await refreshSession()
if (!refreshed) {
if (!cancelled) onLogout()
return
}
session = refreshed
response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
credentials: 'include',
})
}
if (!response.ok) return
const payload = await response.json().catch(() => null) as AuthMeResponse | null
if (!cancelled && payload?.user) {
const nextSession = {
...session,
user: payload.user,
}
if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) {
return
}
onSessionUpdate({
...nextSession,
})
}
}
void loadUserProfile()
return () => {
cancelled = true
}
}, [authSession, onLogout, onSessionUpdate, refreshSession])
useEffect(() => {
let cancelled = false
fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps`)
.then((response) => response.ok ? response.json() : null)
.then((payload: { apps?: LauncherApp[] } | null) => {
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 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)
: undefined
if (!initialAppSelectionDoneRef.current) {
initialAppSelectionDoneRef.current = true
setActiveApp(lastLaunchedApp?.appKey ?? firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
return
}
if (!currentApp || !currentAppAvailable) {
setActiveApp(firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
}
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [activeApp, fetchWithAuthRefresh])
useEffect(() => {
setNoticeOpen(false)
setAppStatus(null)
setShowManual(false)
}, [activeApp])
useEffect(() => {
let cancelled = false
if (!activeApp) {
setAppNotices([])
return
}
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/apps/${encodeURIComponent(activeApp)}`)
.then((response) => response.ok ? response.json() : null)
.then((payload: { notices?: AppNotice[] } | null) => {
if (!cancelled) setAppNotices(payload?.notices ?? [])
})
.catch(() => {
if (!cancelled) setAppNotices([])
})
return () => {
cancelled = true
}
}, [activeApp])
useEffect(() => {
if (!activeApp || !appConfig) {
setAppStatus(null)
return
}
let cancelled = false
const loadStatus = async (): Promise<void> => {
let installation: InstallationInfo = { installed: false, version: null, path: null }
try {
installation = await window.electron.ipcRenderer.invoke('get-app-installation', {
appName: activeApp,
execPath: appConfig.execPath,
devAppPath: appConfig.devAppPath,
}) as InstallationInfo
} catch {
installation = { installed: false, version: null, path: null }
}
if (IS_DEV && appConfig.devAppPath && installation.installed) {
if (!cancelled) {
setAppStatus({
appKey: activeApp,
displayName: appConfig.displayName,
installed: true,
localVersion: installation.version,
latestVersion: installation.version,
status: 'READY',
action: 'LAUNCH',
updateRequired: false,
})
}
return
}
const installed = installedOverrides[activeApp] ?? installation.installed
const currentVersion = installation.version ?? activeLocalVersion ?? ''
if (installation.version) {
setLocalVersions((prev) => ({ ...prev, [activeApp]: installation.version as string }))
}
const query = new URLSearchParams({
currentVersion,
installed: String(installed),
})
try {
const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps/${encodeURIComponent(activeApp)}/update?${query}`)
const status = response.ok ? await response.json() as AppUpdateStatus : null
if (!cancelled) setAppStatus(status)
} catch {
if (!cancelled) setAppStatus(null)
}
}
loadStatus()
return () => {
cancelled = true
}
}, [activeApp, activeLocalVersion, appConfig, fetchWithAuthRefresh, installedOverrides])
useEffect(() => {
let cancelled = false
const query = new URLSearchParams({
currentVersion: version,
installed: 'true',
})
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
.then((response) => response.ok ? response.json() : null)
.then((status: AppUpdateStatus | null) => {
if (!cancelled) setLauncherStatus(status)
})
.catch(() => {
if (!cancelled) setLauncherStatus(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const handler = (_event: unknown, appName: string): void => {
setRunningApps((prev) => {
const next = new Set(prev)
next.delete(appName)
return next
})
}
window.electron.ipcRenderer.on('app-window-closed', handler)
return () => {
window.electron.ipcRenderer.removeListener('app-window-closed', handler)
}
}, [])
useEffect(() => {
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
setUpdateProgressByApp((prev) => ({
...prev,
[payload.appName]: payload.progress,
}))
}
window.electron.ipcRenderer.on('app-download-progress', handler)
return () => {
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
}
}, [])
const completeLocalUpdate = useCallback((appKey: string, latestVersion?: string | null): void => {
if (!latestVersion) return
setLocalVersions((prev) => ({ ...prev, [appKey]: latestVersion }))
setInstalledOverrides((prev) => ({ ...prev, [appKey]: true }))
}, [])
const markAppNoticesRead = useCallback((): void => {
if (!hasNotices) return
setReadNoticeSignatures((prev) => {
const next = {
...prev,
[activeApp]: noticeSignature,
}
window.localStorage.setItem(NOTICE_READ_STORAGE_KEY, JSON.stringify(next))
return next
})
}, [activeApp, hasNotices, noticeSignature])
const handleUpdate = useCallback(async (
targetApp: string,
latestVersion?: string | null,
downloadUrl?: string | null,
) => {
if (!downloadUrl) {
alert('다운로드 URL이 없습니다.')
return
}
if (hasAnyUpdatingApp) {
alert('이미 업데이트가 진행 중입니다.')
return
}
if (targetApp !== 'launcher' && !appConfig) {
alert('선택된 앱이 없습니다.')
return
}
setUpdatingApps((prev) => ({ ...prev, [targetApp]: true }))
setUpdateProgressByApp((prev) => ({ ...prev, [targetApp]: 0 }))
try {
if (targetApp === 'launcher') {
const result = await window.electron.ipcRenderer.invoke('install-launcher-update', {
downloadUrl,
latestVersion,
}) as InstallationInfo
const installedVersion = result.version ?? latestVersion
completeLocalUpdate(targetApp, installedVersion)
setLauncherStatus((prev) => prev
? {
...prev,
installed: true,
localVersion: installedVersion ?? prev.latestVersion,
status: 'READY',
action: 'LAUNCH',
updateRequired: false,
}
: prev)
} else {
const result = await window.electron.ipcRenderer.invoke('download-app', {
appName: targetApp,
downloadUrl,
execPath: appConfig?.execPath,
latestVersion,
}) as InstallationInfo
if (result.version) {
completeLocalUpdate(targetApp, result.version)
} else {
completeLocalUpdate(targetApp, latestVersion)
}
}
} catch (error) {
const message = error instanceof Error ? error.message : '다운로드에 실패했습니다.'
alert(message)
} finally {
setTimeout(() => {
setUpdatingApps((prev) => {
const next = { ...prev }
delete next[targetApp]
return next
})
setUpdateProgressByApp((prev) => {
const next = { ...prev }
delete next[targetApp]
return next
})
}, 500)
}
}, [appConfig, completeLocalUpdate, hasAnyUpdatingApp])
const createHandoffToken = useCallback(async (audienceApp: string): Promise<string | null> => {
const requestHandoff = async (accessToken: string): Promise<Response> => {
return fetch(`${LAUNCHER_API_BASE_URL}/api/auth/handoff`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ audienceApp }),
})
}
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
onSessionUpdate(nextSession)
response = await requestHandoff(nextSession.accessToken)
} else {
alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
onLogout()
return null
}
}
if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
const detail = payload?.message ?? payload?.error ?? `HTTP ${response.status}`
alert(`${audienceApp} 인증 연동에 실패했습니다. (${detail})`)
return null
}
const handoff = await response.json() as { handoffToken: string }
return handoff.handoffToken
}, [authSession.accessToken, onLogout, onSessionUpdate])
const handleLaunch = useCallback(async () => {
if (!appConfig || !canLaunch || launchingApp) return
setLaunchingApp(activeApp)
const launchDelay = new Promise((resolve) => window.setTimeout(resolve, 1500))
let handoffCode: string | undefined
try {
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
if (handoffAudienceApp) {
const [handoffToken] = await Promise.all([
createHandoffToken(handoffAudienceApp),
launchDelay,
])
handoffCode = handoffToken ?? undefined
if (!handoffCode) {
setLaunchingApp(null)
return
}
} else {
await launchDelay
}
window.electron.ipcRenderer.send('launch-app', {
appName: activeApp,
url: appConfig.launchUrl,
devAppPath: appConfig.devAppPath,
execPath: appConfig.execPath,
handoffCode,
audienceApp: resolveHandoffAudienceApp(activeApp) ?? activeApp,
})
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
setRunningApps((prev) => new Set([...prev, activeApp]))
} finally {
setLaunchingApp(null)
}
}, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp])
const handleFocusApp = useCallback(() => {
window.electron.ipcRenderer.send('focus-app-window', activeApp)
}, [activeApp])
const handleCloseApp = useCallback(() => {
window.electron.ipcRenderer.send('close-app-window', activeApp)
}, [activeApp])
useEffect(() => {
if (!noticeOpen) return
const handler = (e: MouseEvent): void => {
if (noticeRef.current && !noticeRef.current.contains(e.target as Node)) {
setNoticeOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [noticeOpen])
return (
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
<TitleBar />
<div className="flex flex-1 overflow-hidden">
<Sidebar
apps={apps}
userName={authSession.user.name || authSession.user.email.split('@')[0] || '사용자'}
userEmail={authSession.user.email}
userRole={authSession.user.role}
userCreatedAt={authSession.user.createdAt}
userLastLoginAt={authSession.user.lastLoginAt}
primaryAffiliation={authSession.user.primaryAffiliation}
currentLicense={authSession.user.currentLicense}
activeApp={activeApp}
onAppChange={setActiveApp}
onLogout={onLogout}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
launcherUpdateAvailable={launcherUpdateAvailable}
launcherUpdating={Boolean(updatingApps.launcher)}
launcherUpdateMessage={launcherUpdateMessage}
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
authAccessToken={authSession.accessToken}
/>
<main className="main-content">
{/* 우상단 영역 */}
<div className="absolute top-[28px] right-[32px] z-10 flex items-start gap-3">
{!showManual && (
<>
{/* 공지사항 버튼 + 드롭다운 */}
<div className="relative" ref={noticeRef}>
<Tooltip label="공지사항">
<button
onClick={() => {
markAppNoticesRead()
setNoticeOpen((v) => !v)
}}
className="notice-icon-btn"
style={{ opacity: hasNotices ? 1 : 0.35, cursor: hasNotices ? 'pointer' : 'default' }}
disabled={!hasNotices}
>
{hasUnreadNotices && <span className="notice-badge" />}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
</Tooltip>
{noticeOpen && hasNotices && (
<div className="notice-dropdown">
<div className="flex items-center justify-between mb-3 px-4 pt-4">
<div className="flex items-center gap-2">
<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) => (
<div key={n.id} className={`notice-item${i < appNotices.length - 1 ? ' notice-item-border' : ''}`}>
<div className="flex items-center justify-between mb-1">
<p className="notice-title">{n.title}</p>
<span className="notice-date">{n.date}</span>
</div>
<p className="notice-body">{n.body}</p>
{n.linkUrl && (
<button
type="button"
className="notice-link-btn"
onClick={() => window.open(n.linkUrl ?? '', '_blank', 'noopener,noreferrer')}
>
자세히보기
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* 업데이트 / 실행 버튼 — 앱 실행 중일 때는 숨김 */}
{!isRunning && hasAppPermission && (
appAction === 'INSTALL' || appAction === 'UPDATE' ? (
<div className="flex flex-col items-end gap-[7px]">
{activeAppUpdating ? (
<button className="updating-btn" disabled>
<svg 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>
{appAction === 'INSTALL' ? '설치중' : '업데이트중'}
</button>
) : (
<button className="update-btn" onClick={() => handleUpdate(activeApp, appStatus?.latestVersion, appStatus?.downloadUrl)} disabled={hasAnyUpdatingApp || !hasAppPermission}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{appAction === 'INSTALL' ? '설치' : '업데이트'}
</button>
)}
{activeAppUpdating && (
<div className="update-progress-wrap">
<div className="update-progress-track">
<div className="update-progress-fill" style={{ width: `${activeUpdateProgress}%` }} />
</div>
<span className="update-progress-pct">{activeUpdateProgress}%</span>
</div>
)}
</div>
) : canLaunch ? (
activeAppLaunching ? (
<button className="launch-pending-btn" disabled>
실행중
</button>
) : (
<button className="launch-btn" onClick={handleLaunch}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
실행
</button>
)
) : (
<button className="updating-btn" disabled>
{appStatus ? '미지원' : '확인중'}
</button>
)
)}
</>
)}
</div>
{/* 메인 콘텐츠 */}
{showManual ? (
<>
{/* 매뉴얼 헤더 바 */}
<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"
>
<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"/>
</svg>
</button>
<span className="text-[13px] font-medium text-[var(--text-secondary)]">사용 매뉴얼</span>
</div>
<iframe
src="appdocs://host/install.html"
className="absolute left-0 right-0 bottom-0 border-none"
style={{ top: '48px', width: '100%', height: 'calc(100% - 48px)', background: '#0a0b0e' }}
/>
</>
) : appConfig ? (
<AppView
app={appConfig}
isRunning={isRunning}
onShowManual={() => setShowManual(true)}
onFocusApp={handleFocusApp}
onCloseApp={handleCloseApp}
/>
) : (
<div className="flex flex-1 items-center justify-center px-10 text-center">
<div>
<p className="text-[15px] font-medium text-[var(--text-secondary)]">연결된 앱이 없습니다.</p>
<p className="mt-2 text-[12px] text-[var(--text-muted)]">관리 콘솔에서 앱을 연결하면 이 화면에 표시됩니다.</p>
</div>
</div>
)}
{!showManual && (
<div className="launcher-version-wrap">
{launcherUpdateAvailable && (
<button
type="button"
className="launcher-update-text"
onClick={() => handleUpdate('launcher', launcherStatus.latestVersion, launcherStatus.downloadUrl)}
disabled={hasAnyUpdatingApp}
>
업데이트
</button>
)}
<div className="version-label">v{localVersions.launcher ?? version}</div>
</div>
)}
</main>
</div>
</div>
)
}
export default Dashboard
+276
View File
@@ -0,0 +1,276 @@
import { useState, useEffect } from 'react'
import TitleBar from '../components/TitleBar'
import LoginForm from './LoginForm'
import type { AuthSession } from './LoginForm'
import PasswordResetForm from './PasswordResetForm'
import SignupForm from './SignupForm'
type View = 'login' | 'signup' | 'password-reset'
type LoginNotice = {
id: string
title: string
body: string
linkUrl: string | null
}
type LauncherUpdateStatus = {
latestVersion: string | null
action: 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
downloadUrl?: string | null
}
interface LoginProps {
onLogin: (session: AuthSession) => void
}
const version = import.meta.env.VITE_APP_VERSION
const TYPING_TEXT = '클라우드 기반 자율주행 모빌리티\n소프트웨어 개발, 검증 및 운영 환경.'
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const DISMISSED_LOGIN_NOTICE_KEY = 'atnmy.login.dismissedNoticeId'
function readDismissedLoginNoticeId(): string {
try {
return window.localStorage.getItem(DISMISSED_LOGIN_NOTICE_KEY) ?? ''
} catch {
return ''
}
}
function storeDismissedLoginNoticeId(noticeId: string): void {
try {
window.localStorage.setItem(DISMISSED_LOGIN_NOTICE_KEY, noticeId)
} catch {
// Notice dismissal should not break the login screen.
}
}
function Login({ onLogin }: LoginProps): React.JSX.Element {
const [view, setView] = useState<View>('login')
const [noticeDismissed, setNoticeDismissed] = useState(false)
const [notice, setNotice] = useState<LoginNotice | null>(null)
const [launcherStatus, setLauncherStatus] = useState<LauncherUpdateStatus | null>(null)
const [updating, setUpdating] = useState(false)
const [updateProgress, setUpdateProgress] = useState(0)
const [typedCount, setTypedCount] = useState(0)
const [loginLoading, setLoginLoading] = useState(false)
useEffect(() => {
let cancelled = false
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/login`)
.then((response) => response.ok ? response.json() : null)
.then((payload: { notice?: LoginNotice | null } | null) => {
if (cancelled) return
const nextNotice = payload?.notice ?? null
setNotice(nextNotice && readDismissedLoginNoticeId() !== nextNotice.id ? nextNotice : null)
})
.catch(() => {
if (!cancelled) setNotice(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
const query = new URLSearchParams({
currentVersion: version,
installed: 'true',
})
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
.then((response) => response.ok ? response.json() : null)
.then((status: LauncherUpdateStatus | null) => {
if (!cancelled) setLauncherStatus(status)
})
.catch(() => {
if (!cancelled) setLauncherStatus(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
if (payload.appName === 'launcher') setUpdateProgress(payload.progress)
}
window.electron.ipcRenderer.on('app-download-progress', handler)
return () => {
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
}
}, [])
const handleLauncherUpdate = async (): Promise<void> => {
if (!launcherStatus?.downloadUrl) {
alert('다운로드 URL이 없습니다.')
return
}
setUpdating(true)
setUpdateProgress(0)
try {
await window.electron.ipcRenderer.invoke('install-launcher-update', {
downloadUrl: launcherStatus.downloadUrl,
latestVersion: launcherStatus.latestVersion,
})
} catch (error) {
const message = error instanceof Error ? error.message : '런처 업데이트에 실패했습니다.'
alert(message)
} finally {
setUpdating(false)
setUpdateProgress(0)
}
}
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined
let cancelled = false
const typeNext = (count: number): void => {
if (cancelled) return
setTypedCount(count)
if (count >= TYPING_TEXT.length) {
timer = setTimeout(() => typeNext(0), 3000)
return
}
timer = setTimeout(() => typeNext(count + 1), 55)
}
const start = setTimeout(() => typeNext(0), 900)
return () => {
cancelled = true
clearTimeout(start)
if (timer) clearTimeout(timer)
}
}, [])
return (
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
<TitleBar />
<div className="flex flex-1 overflow-hidden relative">
{/* 플랫폼 공지 배너 — absolute로 레이아웃 영향 없음 */}
{notice && !noticeDismissed && (
<div className="absolute top-0 left-0 right-0 z-[100] flex items-center gap-3 px-5 py-[9px] bg-[rgba(22,27,42,0.92)] border-b border-[rgba(59,127,250,0.15)]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<p className="flex-1 text-[11.5px] text-[var(--text-secondary)] leading-none">
<span className="text-[var(--accent)] font-semibold mr-2">[{notice.title}]</span>
{notice.body}
{notice.linkUrl && (
<button
type="button"
onClick={() => {
window.open(notice.linkUrl ?? '', '_blank', 'noopener,noreferrer')
}}
className="ml-2 text-[var(--accent)] hover:underline cursor-pointer"
>
자세히 보기
</button>
)}
</p>
<button
type="button"
onClick={() => {
storeDismissedLoginNoticeId(notice.id)
setNoticeDismissed(true)
}}
className="shrink-0 flex items-center justify-center w-5 h-5 rounded text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{loginLoading && (
<div className="absolute inset-0 z-50 bg-[rgba(0,0,0,0.45)] pointer-events-none animate-[fadeIn_0.2s_ease]" />
)}
<div className="login-version-wrap">
{launcherStatus?.action === 'UPDATE' && (
<button
type="button"
className="launcher-update-text"
onClick={handleLauncherUpdate}
disabled={updating}
>
{updating ? `업데이트 ${updateProgress}%` : '업데이트'}
</button>
)}
<div className="version-label">v{version}</div>
</div>
{/* Left — 브랜딩 + 동영상 */}
<div className="flex-1 relative overflow-hidden">
<video className="login-video" src="./video/main_video.mp4" autoPlay loop muted playsInline />
<div className="login-video-overlay" />
<div className="absolute top-[72px] left-[52px] z-[2] animate-reveal">
<h1 className="brand-title" style={{ marginBottom: 14, fontSize: 44 }}>
Autonomy<br /><span>Studio</span>
</h1>
<div className="divider" style={{ marginBottom: 16 }} />
<p className="description">
{(() => {
const text = TYPING_TEXT.slice(0, typedCount)
const [line1, line2] = text.split('\n')
return (
<>
{line1}
{line2 !== undefined && <><br />{line2}</>}
<span className="typing-cursor" />
</>
)
})()}
</p>
</div>
</div>
{/* Right — 슬라이딩 폼 패널 */}
<div className="login-panel w-[420px] shrink-0 relative overflow-hidden">
<div className="login-panel-edge" />
<div className="login-panel-glow" />
<div
className="flex h-full transition-transform duration-[420ms] ease-in-out"
style={{
width: '1260px',
transform: view === 'login'
? 'translateX(0)'
: view === 'signup'
? 'translateX(-420px)'
: 'translateX(-840px)'
}}
>
<div className="w-[420px] flex items-center justify-center">
<LoginForm
onLogin={onLogin}
onGoSignup={() => setView('signup')}
onGoPasswordReset={() => setView('password-reset')}
onLoadingChange={setLoginLoading}
/>
</div>
<div className="w-[420px] flex items-center justify-center">
<SignupForm onGoLogin={() => setView('login')} onSignupComplete={onLogin} />
</div>
<div className="w-[420px] flex items-center justify-center">
<PasswordResetForm onGoLogin={() => setView('login')} />
</div>
</div>
</div>
</div>
</div>
)
}
export default Login
+380
View File
@@ -0,0 +1,380 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
interface LoginFormProps {
onLogin: (session: AuthSession) => void
onGoSignup: () => void
onGoPasswordReset: () => void
onLoadingChange: (v: boolean) => void
}
export interface AuthSession {
accessToken: string
accessTokenExpiresIn?: number
refreshToken?: string
refreshTokenExpiresIn?: number
user: {
id: string
email: string
name: string | null
role: string
roles?: string[]
createdAt?: string
lastLoginAt?: string | null
primaryAffiliation?: {
id: string
organizationId: string
organizationName: string
organizationType: string
department: string | null
position: string | null
} | null
currentLicense?: {
id: string
code: string
name: string
startsAt: string
expiresAt: string | null
} | null
}
}
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 MIN_LOGIN_DURATION_MS = 2000
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
keepLogin: boolean
message: string
}
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) ?? ''
} catch {
return ''
}
}
function storeLastLoginEmail(email: string): void {
try {
window.localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email)
} catch {
// Login should not fail if local storage is unavailable.
}
}
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 (
status?.backendState === 'Unavailable' &&
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
) {
return
}
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())
const [password, setPassword] = useState('qwer1234')
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 emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
storeLastLoginEmail(loginEmail.trim())
if (persistLogin) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
} else {
window.localStorage.removeItem(AUTH_SESSION_KEY)
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
}
const elapsed = Date.now() - startedAt
if (elapsed < MIN_LOGIN_DURATION_MS) {
await delay(MIN_LOGIN_DURATION_MS - elapsed)
}
onLogin(session)
}
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (emailError) return
setLoading(true)
setError('')
setPendingTailscaleConfirm(null)
onLoadingChange(true)
const startedAt = Date.now()
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error('이메일 또는 비밀번호를 확인하세요.')
}
const session = await response.json() as AuthSession
try {
await ensureLauncherTailscale(email, password)
} catch (tailscaleError) {
setLoading(false)
onLoadingChange(false)
setPendingTailscaleConfirm({
session,
email,
keepLogin,
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
})
return
}
await completeLogin(session, email, keepLogin, startedAt)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
} finally {
setLoading(false)
onLoadingChange(false)
}
}
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)
}
}
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>
<div className="flex flex-col gap-3 mb-4">
<div>
<div className="login-input-wrap">
<EnvelopeIcon />
<input
className="login-input-field"
type="email"
placeholder="이메일"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showPassword ? 'text' : 'password'}
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<EyeToggle show={showPassword} onClick={() => setShowPassword((v) => !v)} />
</div>
</div>
<div className="flex items-center justify-between mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={keepLogin}
onChange={(e) => setKeepLogin(e.target.checked)}
className="login-checkbox"
/>
<span className="text-[12px] text-[var(--text-secondary)]">로그인 상태 유지</span>
</label>
<button type="button" onClick={onGoPasswordReset} className="text-[12px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors cursor-pointer">
비밀번호 찾기
</button>
</div>
<div className="mb-3 flex h-5 items-center justify-center">
<p className={`text-center text-[12px] text-red-300 transition-opacity ${error ? 'opacity-100' : 'opacity-0'}`}>
{error || 'placeholder'}
</p>
</div>
<button type="submit" className="login-btn w-full mb-4" disabled={loading || Boolean(emailError)} style={{ opacity: loading || emailError ? 0.7 : 1 }}>
{loading ? '로그인 중...' : '로그인'}
</button>
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-[var(--border)]" />
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
<div className="flex-1 h-px bg-[var(--border)]" />
</div>
<button type="button" className="login-google-btn w-full mb-6">
<GoogleIcon />
Google로 로그인
</button>
<p className="text-center text-[12px] text-[var(--text-muted)]">
계정이 없으신가요?{' '}
<button type="button" onClick={onGoSignup} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
회원가입
</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-[380px] 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">Tailscale 연결 실패</h3>
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.
</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.message}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={() => setPendingTailscaleConfirm(null)}
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"
>
취소
</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
)}
</>
)
}
export default LoginForm
@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import { EnvelopeIcon, EyeToggle, LockIcon } from '../components/AuthIcons'
interface PasswordResetFormProps {
onGoLogin: () => void
}
const AUTH_BASE_URL = (
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
).replace(/\/+$/, '')
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function PasswordResetForm({ onGoLogin }: PasswordResetFormProps): React.JSX.Element {
const [email, setEmail] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false)
const [codeSent, setCodeSent] = useState(false)
const [sendingCode, setSendingCode] = useState(false)
const [codeTimer, setCodeTimer] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
useEffect(() => {
if (codeTimer <= 0) return
const timer = setTimeout(() => setCodeTimer((value) => value - 1), 1000)
return () => clearTimeout(timer)
}, [codeTimer])
const handleSendCode = async (): Promise<void> => {
if (emailError) return
setSendingCode(true)
setVerificationCode('')
setError('')
setSuccess('')
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email }),
})
if (response.status === 404) {
throw new Error('가입되지 않은 이메일입니다.')
}
if (!response.ok) throw new Error('인증코드 발송에 실패했습니다.')
setCodeSent(true)
setCodeTimer(60)
} catch (sendError) {
setCodeSent(false)
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
} finally {
setSendingCode(false)
}
}
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
setError('')
setSuccess('')
if (emailError) {
setError('올바른 이메일 형식으로 입력해주세요.')
return
}
if (!/^\d{6}$/.test(verificationCode)) {
setError('이메일 인증코드 6자리를 입력해주세요.')
return
}
if (password.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다.')
return
}
if (password !== passwordConfirm) {
setError('비밀번호가 일치하지 않습니다.')
return
}
setSubmitting(true)
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email,
verificationCode,
password,
}),
})
if (response.status === 400) {
throw new Error('이메일 또는 인증코드를 확인해주세요.')
}
if (!response.ok) {
throw new Error('비밀번호 변경에 실패했습니다.')
}
setSuccess('비밀번호가 변경되었습니다. 새 비밀번호로 로그인하세요.')
setPassword('')
setPasswordConfirm('')
setVerificationCode('')
setCodeSent(false)
setCodeTimer(0)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '비밀번호 변경에 실패했습니다.')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
<div className="flex items-center gap-2 mb-1">
<button
type="button"
onClick={onGoLogin}
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">비밀번호 찾기</h2>
</div>
<p className="text-[12px] text-[var(--text-muted)] mb-5">이메일 인증 후 새 비밀번호를 설정하세요.</p>
<div className="flex flex-col gap-3 mb-4">
<div>
<div className="login-input-wrap">
<EnvelopeIcon />
<input
className="login-input-field"
type="email"
placeholder="이메일을 입력해주세요."
value={email}
onChange={(e) => {
setEmail(e.target.value)
setCodeSent(false)
setSuccess('')
}}
required
/>
</div>
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
</div>
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: email.length > 0 ? (codeSent ? '238px' : '46px') : '0', opacity: email.length > 0 ? 1 : 0 }}
>
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<div className="login-input-wrap flex-1">
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
<input
className="login-input-field"
type="text"
placeholder="인증코드 6자리"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required={codeSent}
/>
</div>
<button
type="button"
disabled={sendingCode || codeTimer > 0 || !email || Boolean(emailError)}
onClick={handleSendCode}
className="verify-code-btn"
>
{sendingCode
? '발송 중...'
: codeTimer > 0
? `재발송 (${codeTimer}s)`
: codeSent ? '재발송' : '인증코드 발송'}
</button>
</div>
<div
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
>
인증코드가 이메일로 발송되었습니다.
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showPassword ? 'text' : 'password'}
placeholder="새 비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={codeSent}
/>
<EyeToggle show={showPassword} onClick={() => setShowPassword((value) => !value)} />
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showPasswordConfirm ? 'text' : 'password'}
placeholder="새 비밀번호 확인"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required={codeSent}
/>
<EyeToggle show={showPasswordConfirm} onClick={() => setShowPasswordConfirm((value) => !value)} />
</div>
</div>
</div>
</div>
{success && <p className="mb-4 text-center text-[12px] text-[rgba(59,200,130,0.9)]">{success}</p>}
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
<button type="submit" className="login-btn w-full mb-5" disabled={submitting || !codeSent} style={{ opacity: submitting || !codeSent ? 0.7 : 1 }}>
{submitting ? '변경 중...' : '비밀번호 변경'}
</button>
<p className="text-center text-[12px] text-[var(--text-muted)]">
비밀번호가 기억나셨나요?{' '}
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
로그인
</button>
</p>
</form>
)
}
export default PasswordResetForm
+327
View File
@@ -0,0 +1,327 @@
import { useState, useEffect } from 'react'
import { EnvelopeIcon, LockIcon, PersonIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
import type { AuthSession } from './LoginForm'
interface SignupFormProps {
onGoLogin: () => void
onSignupComplete: (session: AuthSession) => void
}
const AUTH_BASE_URL = (
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
).replace(/\/+$/, '')
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const AUTH_SESSION_KEY = 'atnmy.auth.session'
function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX.Element {
const [name, setName] = useState('')
const [signupEmail, setSignupEmail] = useState('')
const [signupPassword, setSignupPassword] = useState('')
const [signupConfirm, setSignupConfirm] = useState('')
const [showSignupPw, setShowSignupPw] = useState(false)
const [showSignupConfirm, setShowSignupConfirm] = useState(false)
const [agreeTerms, setAgreeTerms] = useState(false)
const [agreePrivacy, setAgreePrivacy] = useState(false)
const [verifyCode, setVerifyCode] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [verificationEmail, setVerificationEmail] = useState('')
const [sendingCode, setSendingCode] = useState(false)
const [codeTimer, setCodeTimer] = useState(0)
const [promoCode, setPromoCode] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const emailError = signupEmail.length > 0 && !EMAIL_PATTERN.test(signupEmail) ? '올바른 이메일 형식으로 입력해주세요.' : ''
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
useEffect(() => {
if (codeTimer <= 0) return
const t = setTimeout(() => setCodeTimer((v) => v - 1), 1000)
return () => clearTimeout(t)
}, [codeTimer])
const handleSendCode = async (): Promise<void> => {
if (emailError) return
setSendingCode(true)
setVerifyCode('')
setVerificationEmail('')
setError('')
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/email-verification/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: signupEmail }),
})
if (response.status === 409) {
throw new Error('이미 가입된 이메일입니다.')
}
if (!response.ok) {
throw new Error('인증코드 발송에 실패했습니다.')
}
setCodeSent(true)
setVerificationEmail(normalizedSignupEmail)
setCodeTimer(60)
} catch (sendError) {
setCodeSent(false)
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
} finally {
setSendingCode(false)
}
}
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
setError('')
if (emailError) {
setError('올바른 이메일 형식으로 입력해주세요.')
return
}
if (signupPassword.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다.')
return
}
if (signupPassword !== signupConfirm) {
setError('비밀번호가 일치하지 않습니다.')
return
}
if (!agreeTerms || !agreePrivacy) {
setError('필수 약관에 동의해주세요.')
return
}
if (!/^\d{6}$/.test(verifyCode)) {
setError('이메일 인증코드 6자리를 입력해주세요.')
return
}
if (!codeSent || verificationEmail !== normalizedSignupEmail) {
setError('현재 이메일로 인증코드를 다시 발송해주세요.')
return
}
setSubmitting(true)
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name,
email: signupEmail,
password: signupPassword,
verificationCode: verifyCode,
}),
})
if (response.status === 409) {
throw new Error('이미 가입된 이메일입니다.')
}
if (response.status === 400) {
throw new Error('인증코드가 올바르지 않거나 만료되었습니다.')
}
if (!response.ok) {
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)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
<div className="flex items-center gap-2 mb-1">
<button
type="button"
onClick={onGoLogin}
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">회원가입</h2>
</div>
<p className="text-[12px] text-[var(--text-muted)] mb-5">새 계정을 만들어 서비스를 이용해보세요.</p>
<div className="flex flex-col gap-3 mb-4">
<div className="login-input-wrap">
<PersonIcon />
<input
className="login-input-field"
type="text"
placeholder="이름을 입력해주세요."
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<div className="login-input-wrap">
<EnvelopeIcon />
<input
className="login-input-field"
type="email"
placeholder="이메일을 입력해주세요."
value={signupEmail}
onChange={(e) => {
const nextEmail = e.target.value
const nextNormalizedEmail = nextEmail.trim().toLowerCase()
setSignupEmail(nextEmail)
if (verificationEmail && nextNormalizedEmail !== verificationEmail) {
setCodeSent(false)
setVerifyCode('')
setCodeTimer(0)
}
}}
required
/>
</div>
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
</div>
{/* 인증코드 — 이메일 입력 시 노출 */}
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: signupEmail.length > 0 ? (codeSent ? '78px' : '46px') : '0', opacity: signupEmail.length > 0 ? 1 : 0 }}
>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="login-input-wrap flex-1">
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
<input
className="login-input-field"
type="text"
placeholder="인증코드 6자리"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
/>
</div>
<button
type="button"
disabled={sendingCode || codeTimer > 0 || !signupEmail || Boolean(emailError)}
onClick={handleSendCode}
className="verify-code-btn"
>
{sendingCode
? '발송 중...'
: codeTimer > 0
? `재발송 (${codeTimer}s)`
: codeSent ? '재발송' : '인증코드 발송'}
</button>
</div>
{/* 발송 완료 안내 */}
<div
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
>
인증코드가 이메일로 발송되었습니다.
</div>
</div>
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showSignupPw ? 'text' : 'password'}
placeholder="비밀번호를 입력해주세요."
value={signupPassword}
onChange={(e) => setSignupPassword(e.target.value)}
required
/>
<EyeToggle show={showSignupPw} onClick={() => setShowSignupPw((v) => !v)} />
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showSignupConfirm ? 'text' : 'password'}
placeholder="비밀번호를 다시 입력해주세요."
value={signupConfirm}
onChange={(e) => setSignupConfirm(e.target.value)}
required
/>
<EyeToggle show={showSignupConfirm} onClick={() => setShowSignupConfirm((v) => !v)} />
</div>
{/* 프로모션 코드 (선택) */}
<div className="login-input-wrap">
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
<line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
<input
className="login-input-field"
type="text"
placeholder="프로모션 코드 (선택)"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value)}
/>
</div>
</div>
{/* 약관 동의 */}
<div className="flex flex-col gap-[10px] mb-5">
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={agreeTerms} onChange={(e) => setAgreeTerms(e.target.checked)} className="login-checkbox" required />
<span className="text-[12px] text-[var(--text-secondary)]">
이용약관에 동의합니다. <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>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} className="login-checkbox" required />
<span className="text-[12px] text-[var(--text-secondary)]">
개인정보 처리방침에 동의합니다. <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>
</div>
</div>
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
<button type="submit" className="login-btn w-full mb-4" disabled={submitting} style={{ opacity: submitting ? 0.7 : 1 }}>
{submitting ? '가입 중...' : '회원가입'}
</button>
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-[var(--border)]" />
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
<div className="flex-1 h-px bg-[var(--border)]" />
</div>
<button type="button" className="login-google-btn w-full mb-5">
<GoogleIcon />
Google로 회원가입
</button>
<p className="text-center text-[12px] text-[var(--text-muted)]">
이미 계정이 있나요?{' '}
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
로그인
</button>
</p>
</form>
)
}
export default SignupForm