Add enrollment, login, and auth-gated routing to the frontend
- New AuthContext drives app state (loading/needs-setup/enrolling/ logged-out/logged-in) by checking GET /api/system/setup-status and GET /api/auth/me on load; JWT stored in localStorage - Enrollment page: step 1 creates the admin account via POST /api/setup, step 2 lets you connect integrations (or skip) before entering the app - Login page for returning sessions; TopBar's Sign Out now calls logout() instead of being a dead link - Verified end-to-end in a browser: fresh setup -> connect/skip -> dashboard, reload persists the session, sign out -> login -> back in Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
a0b71c7028
commit
e2793b06fe
7 changed files with 599 additions and 3 deletions
19
src/App.tsx
19
src/App.tsx
|
|
@ -6,8 +6,27 @@ import Glance from './pages/Glance'
|
|||
import Infrastructure from './pages/Infrastructure'
|
||||
import BookNest from './pages/BookNest'
|
||||
import Settings from './pages/Settings'
|
||||
import Login from './pages/Login'
|
||||
import Enrollment from './pages/Enrollment'
|
||||
import { useAuth } from './lib/AuthContext'
|
||||
|
||||
function App() {
|
||||
const { status } = useAuth()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
||||
<p style={{ color: '#7A7D85', fontSize: '13px' }}>Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'needs-setup' || status === 'enrolling') return <Enrollment />
|
||||
if (status === 'logged-out') return <Login />
|
||||
|
||||
return <Dashboard />
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const sidebarWidth = sidebarCollapsed ? 64 : 200
|
||||
const location = useLocation()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle } from 'lucide-react'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': 'Glance',
|
||||
|
|
@ -15,6 +16,7 @@ const pageSubtitles: Record<string, string> = {
|
|||
}
|
||||
|
||||
export default function TopBar() {
|
||||
const { logout } = useAuth()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const location = useLocation()
|
||||
|
|
@ -113,10 +115,13 @@ export default function TopBar() {
|
|||
</a>
|
||||
</div>
|
||||
<div className="border-t border-border py-1">
|
||||
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-danger hover:bg-page transition-colors no-underline">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-[12px] text-danger hover:bg-page transition-colors cursor-pointer border-none bg-transparent text-left"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign Out</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
83
src/lib/AuthContext.tsx
Normal file
83
src/lib/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api, getToken, setToken } from './api'
|
||||
|
||||
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in'
|
||||
|
||||
interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
display_name: string | null
|
||||
email: string | null
|
||||
avatar_data_url: string | null
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus
|
||||
user: AuthUser | null
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
completeSetup: (username: string, password: string) => Promise<void>
|
||||
finishEnrollment: () => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>('loading')
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
if (getToken()) {
|
||||
try {
|
||||
const { user } = await api.me()
|
||||
setUser(user)
|
||||
setStatus('logged-in')
|
||||
return
|
||||
} catch {
|
||||
setToken(null)
|
||||
}
|
||||
}
|
||||
const { needsSetup } = await api.getSetupStatus()
|
||||
setStatus(needsSetup ? 'needs-setup' : 'logged-out')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const { token } = await api.login(username, password)
|
||||
setToken(token)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function completeSetup(username: string, password: string) {
|
||||
const { token } = await api.setup(username, password)
|
||||
setToken(token)
|
||||
const { user } = await api.me()
|
||||
setUser(user)
|
||||
setStatus('enrolling')
|
||||
}
|
||||
|
||||
async function finishEnrollment() {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setStatus('logged-out')
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
96
src/lib/api.ts
Normal file
96
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
const TOKEN_KEY = 'archnest_token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`/api${path}`, { ...options, headers })
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = await res.json()
|
||||
message = body.error ?? message
|
||||
} catch {
|
||||
// ignore non-JSON error bodies
|
||||
}
|
||||
throw new ApiError(res.status, message)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'),
|
||||
setup: (username: string, password: string) =>
|
||||
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
login: (username: string, password: string) =>
|
||||
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
me: () => apiFetch<{ user: { id: number; username: string; display_name: string | null; email: string | null; avatar_data_url: string | null } }>('/auth/me'),
|
||||
|
||||
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
|
||||
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
|
||||
apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateIntegration: (id: number, data: Partial<{ name: string; config: Record<string, string>; secrets: Record<string, string> }>) =>
|
||||
apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteIntegration: (id: number) => apiFetch<void>(`/integrations/${id}`, { method: 'DELETE' }),
|
||||
testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }),
|
||||
|
||||
listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'),
|
||||
listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'),
|
||||
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
|
||||
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
|
||||
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
status: string
|
||||
config: Record<string, string>
|
||||
lastCheckedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: number
|
||||
category_id: number | null
|
||||
title: string
|
||||
url: string
|
||||
icon: string | null
|
||||
favorite: number
|
||||
status: string
|
||||
last_checked_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BookmarkCategory {
|
||||
id: number
|
||||
name: string
|
||||
icon: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
|
@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
|
|||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './lib/AuthContext'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
275
src/pages/Enrollment.tsx
Normal file
275
src/pages/Enrollment.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useState } from 'react'
|
||||
import { Server, Container, Network, Cloud, CloudCog, Activity, CloudSun, Check, ArrowRight } from 'lucide-react'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.12)',
|
||||
borderRadius: '14px',
|
||||
padding: '32px',
|
||||
}
|
||||
|
||||
const fieldLabel: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
color: '#7A7D85',
|
||||
marginBottom: '6px',
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const fieldInput: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(200,164,52,0.12)',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
color: '#E8E6E0',
|
||||
fontSize: '13px',
|
||||
padding: '0 12px',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
const goldButton: React.CSSProperties = {
|
||||
height: '38px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
||||
padding: '0 20px',
|
||||
}
|
||||
|
||||
const integrationOptions = [
|
||||
{ type: 'proxmox', name: 'Proxmox', icon: Server },
|
||||
{ type: 'docker', name: 'Docker', icon: Container },
|
||||
{ type: 'netbird', name: 'NetBird', icon: Network },
|
||||
{ type: 'cloudflare', name: 'Cloudflare', icon: Cloud },
|
||||
{ type: 'aws', name: 'AWS', icon: CloudCog },
|
||||
{ type: 'uptime_kuma', name: 'Uptime Kuma', icon: Activity },
|
||||
{ type: 'weather', name: 'Weather API', icon: CloudSun },
|
||||
] as const
|
||||
|
||||
export default function Enrollment() {
|
||||
const { status, completeSetup, finishEnrollment } = useAuth()
|
||||
const step = status === 'enrolling' ? 'connect' : 'account'
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
||||
<div style={{ width: step === 'account' ? '380px' : '640px' }}>
|
||||
{step === 'account' ? (
|
||||
<AccountStep completeSetup={completeSetup} />
|
||||
) : (
|
||||
<ConnectStep onFinish={finishEnrollment} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountStep({
|
||||
completeSetup,
|
||||
}: {
|
||||
completeSetup: (username: string, password: string) => Promise<void>
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await completeSetup(username, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Setup failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={cardBase}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#C8A434',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Welcome to ArchNest
|
||||
</h1>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
||||
Create your admin account to get started
|
||||
</p>
|
||||
|
||||
<label style={fieldLabel}>Username</label>
|
||||
<input style={fieldInput} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus required />
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>Password</label>
|
||||
<input style={fieldInput} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>Confirm Password</label>
|
||||
<input style={fieldInput} type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||
|
||||
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
<button type="submit" disabled={submitting} style={{ ...goldButton, width: '100%', marginTop: '22px', opacity: submitting ? 0.6 : 1 }}>
|
||||
{submitting ? 'Creating account…' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectStep({ onFinish }: { onFinish: () => Promise<void> }) {
|
||||
const [connected, setConnected] = useState<Set<string>>(new Set())
|
||||
const [active, setActive] = useState<(typeof integrationOptions)[number] | null>(null)
|
||||
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#C8A434',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Connect Your Services
|
||||
</h1>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
||||
Add the integrations you want ArchNest to monitor. You can also do this later from Settings.
|
||||
</p>
|
||||
|
||||
{active ? (
|
||||
<ConnectForm
|
||||
option={active}
|
||||
onConnected={() => {
|
||||
setConnected((prev) => new Set(prev).add(active.type))
|
||||
setActive(null)
|
||||
}}
|
||||
onCancel={() => setActive(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{integrationOptions.map((opt) => {
|
||||
const Icon = opt.icon
|
||||
const isDone = connected.has(opt.type)
|
||||
return (
|
||||
<button
|
||||
key={opt.type}
|
||||
onClick={() => setActive(opt)}
|
||||
className="flex flex-col items-center gap-2 cursor-pointer transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${isDone ? 'rgba(46,204,113,0.4)' : 'rgba(200,164,52,0.12)'}`,
|
||||
borderRadius: '10px',
|
||||
padding: '18px 8px',
|
||||
backgroundColor: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
{isDone ? <Check size={20} color="#2ECC71" /> : <Icon size={20} color="#C8A434" />}
|
||||
<span style={{ fontSize: '11px', color: '#E8E6E0' }}>{opt.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!active && (
|
||||
<div className="flex justify-end" style={{ marginTop: '24px' }}>
|
||||
<button
|
||||
onClick={() => onFinish()}
|
||||
className="cursor-pointer"
|
||||
style={{ ...goldButton, display: 'inline-flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
{connected.size > 0 ? 'Finish' : 'Skip for now'} <ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectForm({
|
||||
option,
|
||||
onConnected,
|
||||
onCancel,
|
||||
}: {
|
||||
option: (typeof integrationOptions)[number]
|
||||
onConnected: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setTestResult(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const { integration } = await api.createIntegration({
|
||||
type: option.type,
|
||||
name: option.name,
|
||||
config: baseUrl ? { baseUrl } : {},
|
||||
secrets: apiKey ? { apiKey } : {},
|
||||
})
|
||||
const result = await api.testIntegration(integration.id)
|
||||
setTestResult(result.message)
|
||||
if (result.ok) onConnected()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to add integration')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '16px', fontWeight: 600 }}>{option.name}</p>
|
||||
|
||||
<label style={fieldLabel}>Base URL</label>
|
||||
<input style={fieldInput} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://..." />
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>API Key (optional)</label>
|
||||
<input style={fieldInput} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
|
||||
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
||||
{testResult && <p style={{ fontSize: '12px', color: '#7A7D85', marginTop: '12px' }}>{testResult}</p>}
|
||||
|
||||
<div className="flex gap-3" style={{ marginTop: '20px' }}>
|
||||
<button type="submit" disabled={submitting} style={{ ...goldButton, opacity: submitting ? 0.6 : 1 }}>
|
||||
{submitting ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer"
|
||||
style={{ height: '38px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: '#7A7D85', fontSize: '13px', padding: '0 16px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
115
src/pages/Login.tsx
Normal file
115
src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useState } from 'react'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
width: '380px',
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.12)',
|
||||
borderRadius: '14px',
|
||||
padding: '32px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#C8A434',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
ArchNest
|
||||
</h1>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>Sign in to your dashboard</p>
|
||||
|
||||
<label style={fieldLabel}>Username</label>
|
||||
<input
|
||||
style={fieldInput}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>Password</label>
|
||||
<input
|
||||
style={fieldInput}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="cursor-pointer transition-opacity"
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '22px',
|
||||
height: '38px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
||||
opacity: submitting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldLabel: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
color: '#7A7D85',
|
||||
marginBottom: '6px',
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const fieldInput: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(200,164,52,0.12)',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
color: '#E8E6E0',
|
||||
fontSize: '13px',
|
||||
padding: '0 12px',
|
||||
outline: 'none',
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue