Public Access
feat: implement setup wizard for initial configuration and user registration
This commit is contained in:
1 parent
f7b93f8612
commit
ad777ef1c3
9 files changed
+576
-8
No files matched your search
+3
-1
@@ -1,4 +1,6 @@
|
||||
backend/brainwave.db-shm
|
||||
backend/brainwave.db-wal
|
||||
.claude/settings.local.json
|
||||
.superpowers/
|
||||
.superpowers/
|
||||
backend/db/brainwave.db-shm
|
||||
backend/db/brainwave.db-wal
|
||||
@@ -1,4 +1,5 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { runMigrations } from './migrations.js'
|
||||
@@ -10,6 +11,7 @@ let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
@@ -5,6 +5,7 @@ import authRoutes from './routes/auth.js'
|
||||
import examRoutes from './routes/exams.js'
|
||||
import resultRoutes from './routes/results.js'
|
||||
import adminRoutes from './routes/admin.js'
|
||||
import setupRoutes from './routes/setup.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -24,6 +25,7 @@ getDb()
|
||||
console.log('✓ Database initialized')
|
||||
|
||||
// Routes
|
||||
app.use('/api/setup', setupRoutes)
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/exams', examRoutes)
|
||||
app.use('/api/results', resultRoutes)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDb } from '../db/database.js'
|
||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||
import { sendVerificationEmail, sendPasswordResetEmail, isEmailConfigured } from '../utils/email.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import { needsSetup } from './setup.js'
|
||||
|
||||
const router = Router()
|
||||
const BCRYPT_ROUNDS = 12
|
||||
@@ -13,6 +14,11 @@ const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
|
||||
// ─── Register ────────────────────────────────────────────
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
if (needsSetup()) {
|
||||
res.status(403).json({ error: 'Initial setup is required before registration' })
|
||||
return
|
||||
}
|
||||
|
||||
const { email, password, displayName } = req.body
|
||||
|
||||
if (!email || !password || !displayName) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Router } from 'express'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { signToken } from '../utils/tokens.js'
|
||||
|
||||
const router = Router()
|
||||
const BCRYPT_ROUNDS = 12
|
||||
|
||||
function needsSetup(): boolean {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
|
||||
return row.cnt === 0
|
||||
}
|
||||
|
||||
// ─── Check Setup Status ──────────────────────────────────
|
||||
router.get('/status', (_req, res) => {
|
||||
try {
|
||||
res.json({ needsSetup: needsSetup() })
|
||||
} catch (err) {
|
||||
console.error('Setup status error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Perform Initial Setup ───────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
if (!needsSetup()) {
|
||||
res.status(403).json({ error: 'Setup has already been completed' })
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
smtpPass,
|
||||
smtpFrom,
|
||||
smtpSecure,
|
||||
appUrl,
|
||||
} = req.body
|
||||
|
||||
// Validate admin credentials
|
||||
if (!email || !password || !displayName) {
|
||||
res.status(400).json({ error: 'Email, password, and display name are required' })
|
||||
return
|
||||
}
|
||||
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
|
||||
res.status(400).json({ error: 'Invalid email address' })
|
||||
return
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 8) {
|
||||
res.status(400).json({ error: 'Password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
|
||||
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
// Create admin user (auto-verified since no SMTP yet)
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, password_hash, display_name, role, email_verified)
|
||||
VALUES (?, ?, ?, 'admin', 1)
|
||||
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim())
|
||||
|
||||
const userId = result.lastInsertRowid as number
|
||||
|
||||
// Save settings
|
||||
const settings: Record<string, string> = {
|
||||
app_url: typeof appUrl === 'string' && appUrl.trim() ? appUrl.trim() : 'http://localhost:5173',
|
||||
}
|
||||
|
||||
if (typeof smtpHost === 'string' && smtpHost.trim()) {
|
||||
settings.smtp_host = smtpHost.trim()
|
||||
settings.smtp_port = String(Number(smtpPort) || 587)
|
||||
settings.smtp_user = typeof smtpUser === 'string' ? smtpUser : ''
|
||||
settings.smtp_pass = typeof smtpPass === 'string' ? smtpPass : ''
|
||||
settings.smtp_from = typeof smtpFrom === 'string' && smtpFrom.trim() ? smtpFrom.trim() : 'BrainWave <noreply@brainwave.local>'
|
||||
settings.smtp_secure = typeof smtpSecure === 'string' ? smtpSecure : 'auto'
|
||||
}
|
||||
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO site_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
|
||||
`)
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
upsert.run(key, value)
|
||||
}
|
||||
|
||||
// Generate JWT and log admin in immediately
|
||||
const token = signToken({
|
||||
userId,
|
||||
email: email.toLowerCase().trim(),
|
||||
displayName: displayName.trim(),
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Setup completed successfully',
|
||||
token,
|
||||
user: {
|
||||
id: userId,
|
||||
email: email.toLowerCase().trim(),
|
||||
displayName: displayName.trim(),
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Setup error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
export { needsSetup }
|
||||
+12
-1
@@ -28,6 +28,7 @@ import AuthModal from "./components/AuthModal";
|
||||
import EmailVerify from "./components/EmailVerify";
|
||||
import ResetPassword from "./components/ResetPassword";
|
||||
import AdminDashboard from "./components/AdminDashboard";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
|
||||
const DEFAULT_TWEAKS: Tweaks = {
|
||||
theme: "light",
|
||||
@@ -41,7 +42,17 @@ const withNotes = (state: ExamState): ExamState => ({
|
||||
});
|
||||
|
||||
function AppInner() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
|
||||
|
||||
if (needsSetup) {
|
||||
return (
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
void refreshUser()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [exam, setExam] = useState<Exam | null>(null);
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useState } from 'react'
|
||||
import { apiFetch, setToken } from '../utils/api'
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export default function SetupWizard({ onComplete }: Readonly<Props>) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [email, setEmail] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [smtpHost, setSmtpHost] = useState('')
|
||||
const [smtpPort, setSmtpPort] = useState('587')
|
||||
const [smtpUser, setSmtpUser] = useState('')
|
||||
const [smtpPass, setSmtpPass] = useState('')
|
||||
const [smtpFrom, setSmtpFrom] = useState('')
|
||||
const [smtpSecure, setSmtpSecure] = useState('auto')
|
||||
const [appUrl, setAppUrl] = useState(window.location.origin)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const validateStep1 = () => {
|
||||
if (!email.trim() || !email.includes('@')) {
|
||||
setError('Please enter a valid email address')
|
||||
return false
|
||||
}
|
||||
if (!displayName.trim()) {
|
||||
setError('Display name is required')
|
||||
return false
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return false
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setError(null)
|
||||
if (validateStep1()) setStep(2)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const data = await apiFetch<{
|
||||
message: string
|
||||
token: string
|
||||
user: {
|
||||
id: number
|
||||
email: string
|
||||
displayName: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
}>('/api/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
displayName: displayName.trim(),
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpUser,
|
||||
smtpPass,
|
||||
smtpFrom: smtpFrom || 'BrainWave <noreply@brainwave.local>',
|
||||
smtpSecure,
|
||||
appUrl,
|
||||
}),
|
||||
})
|
||||
setToken(data.token)
|
||||
onComplete()
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Setup failed. Please try again.',
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setup-page">
|
||||
<div className="setup-card">
|
||||
<div className="brand-mark" style={{ margin: '0 auto 16px' }} />
|
||||
<h1 className="setup-title">Welcome to BrainWave</h1>
|
||||
<p className="setup-subtitle">
|
||||
Let's get your instance set up. This only takes a minute.
|
||||
</p>
|
||||
|
||||
<div className="setup-steps">
|
||||
<div className={`setup-step ${step >= 1 ? 'active' : ''}`}>
|
||||
<span className="setup-step-num">1</span>
|
||||
<span className="setup-step-label">Admin Account</span>
|
||||
</div>
|
||||
<div className="setup-step-line" />
|
||||
<div className={`setup-step ${step >= 2 ? 'active' : ''}`}>
|
||||
<span className="setup-step-num">2</span>
|
||||
<span className="setup-step-label">Email Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="setup-email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="setup-email"
|
||||
className="input"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="setup-name">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
id="setup-name"
|
||||
className="input"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="setup-password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="setup-confirm">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="setup-confirm"
|
||||
className="input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary auth-submit"
|
||||
onClick={handleNext}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
Continue →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<form onSubmit={(e) => void handleSubmit(e)}>
|
||||
<p className="setup-hint">
|
||||
Configure your outgoing email server so BrainWave can send
|
||||
verification and password-reset emails.
|
||||
</p>
|
||||
<p className="setup-hint">
|
||||
You can leave these blank and configure them later in the admin
|
||||
dashboard.
|
||||
</p>
|
||||
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="smtp-host">
|
||||
SMTP Host
|
||||
</label>
|
||||
<input
|
||||
id="smtp-host"
|
||||
className="input"
|
||||
type="text"
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="smtp-port">
|
||||
SMTP Port
|
||||
</label>
|
||||
<input
|
||||
id="smtp-port"
|
||||
className="input"
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(e.target.value)}
|
||||
placeholder="587"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="smtp-user">
|
||||
SMTP Username
|
||||
</label>
|
||||
<input
|
||||
id="smtp-user"
|
||||
className="input"
|
||||
type="text"
|
||||
value={smtpUser}
|
||||
onChange={(e) => setSmtpUser(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="smtp-pass">
|
||||
SMTP Password
|
||||
</label>
|
||||
<input
|
||||
id="smtp-pass"
|
||||
className="input"
|
||||
type="password"
|
||||
value={smtpPass}
|
||||
onChange={(e) => setSmtpPass(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="smtp-from">
|
||||
From Address
|
||||
</label>
|
||||
<input
|
||||
id="smtp-from"
|
||||
className="input"
|
||||
type="text"
|
||||
value={smtpFrom}
|
||||
onChange={(e) => setSmtpFrom(e.target.value)}
|
||||
placeholder="BrainWave <noreply@brainwave.local>"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="smtp-secure">
|
||||
Security
|
||||
</label>
|
||||
<select
|
||||
id="smtp-secure"
|
||||
className="input"
|
||||
value={smtpSecure}
|
||||
onChange={(e) => setSmtpSecure(e.target.value)}
|
||||
>
|
||||
<option value="auto">Auto (use SSL on port 465)</option>
|
||||
<option value="true">SSL/TLS</option>
|
||||
<option value="false">None / STARTTLS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="app-url">
|
||||
App URL
|
||||
</label>
|
||||
<input
|
||||
id="app-url"
|
||||
className="input"
|
||||
type="url"
|
||||
value={appUrl}
|
||||
onChange={(e) => setAppUrl(e.target.value)}
|
||||
placeholder="https://brainwave.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary auth-submit"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Setting up…' : 'Complete Setup'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface User {
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
needsSetup: boolean
|
||||
setUser: (user: User | null) => void
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (email: string, password: string, displayName: string) => Promise<string>
|
||||
logout: () => void
|
||||
@@ -23,14 +25,25 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [needsSetup, setNeedsSetup] = useState(false)
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (!isAuthenticated()) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Always check setup status first
|
||||
const setupStatus = await apiFetch<{ needsSetup: boolean }>('/api/setup/status')
|
||||
if (setupStatus.needsSetup) {
|
||||
setNeedsSetup(true)
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setNeedsSetup(false)
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
|
||||
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
|
||||
} catch {
|
||||
@@ -68,7 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, needsSetup, setUser, login, register, logout, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -2294,3 +2294,100 @@ select {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Setup Wizard
|
||||
============================================================ */
|
||||
|
||||
.setup-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 32px 28px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
text-align: center;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.setup-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--ink-3);
|
||||
text-align: center;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.setup-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-4);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setup-step.active {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.setup-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--line);
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-step.active .setup-step-num {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setup-step-line {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
font-size: 13px;
|
||||
color: var(--ink-3);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
Reference in new issue
Block a user