Felis never actually sent mail: OTP codes for onboarding, email login and
op-login were only written to the felis-api log behind a "demo has no SMTP"
limitation, and the Settings/SMTP flow those comments promised was never
built. Combined with the bootstrap Owner's address being recorded unverified
(87279a1), op-login start always took the anti-enumeration neutral branch and
minted a fake request_id, so the in-game approve inevitably answered "No
pending operator sign-in with that code".
Give the codes a real delivery path, configured in felis.toml rather than a
web settings page so config keeps a single source of truth:
- config: new [smtp] table (host, port defaulting to 587, from, username,
password_ref). Validation requires a plausible from address and a sane
port; the password itself never enters the config file.
- internal/mail (new): stdlib net/smtp mailer implementing the api.OTPMailer
seam. Port 465 dials implicit TLS, other ports upgrade via STARTTLS when
advertised; AUTH only when a username is configured (PlainAuth itself
refuses plaintext, so the password cannot leak to a TLS-less relay).
Ping() proves reachability and credentials without sending mail. The
message shape (CRLF, Q-encoded bilingual subject) is pinned by test.
- platform: felis-smtp Secret constants and an optional FELIS_SMTP_PASSWORD
env var on the felis-api Deployment, mirroring felis-uploads-s3.
- cmd/felis api: construct the real mailer when [smtp] is configured; keep
the log fallback otherwise and say so at startup. Warn when a username is
set but the credentials env is empty.
- setup TUI: "e" on the summary/status screen opens the email form (host,
port, from, optional auth). Apply order: Ping preflight, [smtp] into both
host and pod config files, felis-smtp Secret piped to kubectl via stdin,
config Secret, felis-api rollout. A failed preflight leaves the install
untouched. SMTP is deliberately not a wizard rail step: first-run stays
mail-less by design, and the passkey minted at onboarding is the pre-SMTP
owner credential.
Also make PGRepo.UserByEmail match case-insensitively (lower(email) =
lower($1)), honoring the interface contract and the users_verified_email_
unique partial index; the fake repo already matched with EqualFold.
Existing installs need the felis-api Deployment manifest re-applied (e.g. a
bootstrap re-run) before the new env var exists; a rollout restart alone
cannot add it.
86 lines
3.0 KiB
Go
86 lines
3.0 KiB
Go
package main
|
||
|
||
import (
|
||
"strings"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
// summaryModel is the terminal screen of the setup wizard. On a first run it
|
||
// confirms what was just configured and points the operator at the panel for
|
||
// everything else. On a re-run (an Owner already exists) it doubles as a thin
|
||
// status landing screen — the whole point of the redesign is that setup runs
|
||
// once and the rest of administration happens in the panel.
|
||
type summaryModel struct {
|
||
panelURL string
|
||
ownerUsername string
|
||
setupTokenURL string // one-time first-login URL; shown once
|
||
accessLabel string
|
||
storageLabel string // build-context storage backend recap; empty to omit
|
||
routedHosts []string
|
||
alreadySetUp bool // re-run: Owner pre-existed
|
||
localHint bool // show the self-signed-cert note
|
||
}
|
||
|
||
func (m *summaryModel) Init() tea.Cmd { return nil }
|
||
|
||
// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
|
||
// the summary takes no text input, so the horizontal arrows are free.
|
||
func (m *summaryModel) arrowNavOK() bool { return true }
|
||
|
||
func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
if key, ok := msg.(tea.KeyMsg); ok {
|
||
switch key.String() {
|
||
case "c", "C":
|
||
return m, func() tea.Msg { return reconfigureConnectMsg{} }
|
||
case "s", "S":
|
||
return m, func() tea.Msg { return reconfigureStorageMsg{} }
|
||
case "e", "E":
|
||
return m, func() tea.Msg { return reconfigureSMTPMsg{} }
|
||
case "ctrl+c", "esc", "enter", "q":
|
||
return m, tea.Quit
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *summaryModel) View() string {
|
||
var b strings.Builder
|
||
|
||
if m.alreadySetUp {
|
||
b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n")
|
||
} else {
|
||
b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n")
|
||
}
|
||
|
||
var card strings.Builder
|
||
if m.ownerUsername != "" {
|
||
card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n")
|
||
}
|
||
if m.setupTokenURL != "" {
|
||
card.WriteString(tuiLabel.Render("setup URL ") + tuiPassword.Render(m.setupTokenURL) + "\n")
|
||
card.WriteString(" " + tuiWarn.Render("one-time link — open it to finish login setup") + "\n")
|
||
}
|
||
if m.accessLabel != "" {
|
||
card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n")
|
||
}
|
||
if m.storageLabel != "" {
|
||
card.WriteString(tuiLabel.Render("storage ") + m.storageLabel + "\n")
|
||
}
|
||
if len(m.routedHosts) > 0 {
|
||
card.WriteString(tuiLabel.Render("routed ") + strings.Join(m.routedHosts, ", ") + "\n")
|
||
}
|
||
if m.panelURL != "" {
|
||
card.WriteString(tuiLabel.Render("panel ") + m.panelURL + "\n")
|
||
}
|
||
b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
|
||
|
||
b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n")
|
||
if m.localHint {
|
||
b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n")
|
||
}
|
||
|
||
b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit"))
|
||
return b.String()
|
||
}
|