Add admin-invited signups and email notifications (Gitea issue #15)

Site admins can invite a brand-new person by email from the Admin portal
Users tab -- a signup-link email lets them set their own username/password
and lands them in the app already logged in. Existing users invited to a
room now also get an email. Closes the "invited but never notified" gap
from both directions.

SMTP is configured through the Admin Settings tab at runtime (not the env
file), persisted in a new smtp_settings table with the password encrypted
at rest via a Fernet key derived from SESSION_SECRET -- the first
reversible secret this app stores in the database. A "send test email"
button surfaces real delivery errors; the invite/notification paths
themselves never fail loudly, since an SMTP outage shouldn't block an
action that already succeeded in the database.

New site_invites table mirrors RoomInvite's shape but targets an email
address with no room context; the raw signup token is hashed the same way
API tokens are, and only ever exists in the email link. POST /api/signup
is the first genuinely public, unauthenticated account-creation endpoint
in this app, reusing the existing register_user path for identical
validation.
This commit is contained in:
2026-08-14 17:38:56 -06:00
parent ad1beccd3a
commit b724f8a33b
28 changed files with 1561 additions and 20 deletions
+2
View File
@@ -3,6 +3,7 @@ import { AuthProvider } from './context/AuthContext'
import { AdminRoute } from './components/AdminRoute'
import { ProtectedRoute } from './components/ProtectedRoute'
import { LoginPage } from './pages/LoginPage'
import { SignupPage } from './pages/SignupPage'
import { ChatShellPage } from './pages/ChatShellPage'
import { AdminPage } from './pages/AdminPage'
@@ -11,6 +12,7 @@ function App() {
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route
path="/rooms"
element={
+41
View File
@@ -4,6 +4,8 @@ import type {
AdminUser,
AuditLogEntry,
EventSubscriptionAdmin,
SiteInvite,
SmtpSettings,
WebhookIncomingAdmin,
} from '../types'
@@ -64,3 +66,42 @@ export function listAllIncomingWebhooks(): Promise<WebhookIncomingAdmin[]> {
export function listAllEventSubscriptions(): Promise<EventSubscriptionAdmin[]> {
return apiFetch<EventSubscriptionAdmin[]>('/api/admin/event-subscriptions')
}
export function inviteUser(email: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>('/api/admin/invites', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
export function listSiteInvites(): Promise<SiteInvite[]> {
return apiFetch<SiteInvite[]>('/api/admin/invites')
}
export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' })
}
export function getSmtpSettings(): Promise<SmtpSettings | null> {
return apiFetch<SmtpSettings | null>('/api/admin/settings/smtp')
}
export interface SmtpSettingsPayload {
host: string
port: number
username?: string | null
password?: string | null
from_address: string
use_tls: boolean
}
export function updateSmtpSettings(payload: SmtpSettingsPayload): Promise<SmtpSettings> {
return apiFetch<SmtpSettings>('/api/admin/settings/smtp', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function sendTestSmtpEmail(): Promise<void> {
return apiFetch<void>('/api/admin/settings/smtp/test', { method: 'POST' })
}
+17
View File
@@ -0,0 +1,17 @@
import { apiFetch } from './client'
import type { User } from '../types'
export function validateSignupToken(token: string): Promise<{ email: string }> {
return apiFetch<{ email: string }>(`/api/signup/validate?token=${encodeURIComponent(token)}`)
}
export function completeSignup(
token: string,
username: string,
password: string,
): Promise<User> {
return apiFetch<User>('/api/signup', {
method: 'POST',
body: JSON.stringify({ token, username, password }),
})
}
+64
View File
@@ -228,3 +228,67 @@
font-size: 0.95rem;
margin: var(--sp-6) 0 var(--sp-3);
}
.admin-settings-form {
display: flex;
flex-direction: column;
gap: var(--sp-4);
max-width: 480px;
}
.admin-settings-row {
display: flex;
gap: var(--sp-3);
}
.admin-settings-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.78rem;
color: var(--ds-muted);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-settings-field-narrow {
flex: 0 0 120px;
}
.admin-settings-field input {
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 9px 11px;
font-size: 0.9rem;
color: var(--ds-text);
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.admin-settings-field input:focus {
border-color: var(--ds-accent);
outline: none;
}
.admin-settings-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.86rem;
color: var(--ds-text);
}
.admin-settings-actions {
display: flex;
gap: var(--sp-2);
}
.admin-settings-test-result {
font-size: 0.84rem;
color: var(--ds-muted);
margin: 0;
}
+234 -5
View File
@@ -1,19 +1,25 @@
import { Fragment, useEffect, useState } from 'react'
import { Fragment, useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import {
archiveRoom,
deactivateUser,
demoteUser,
getSmtpSettings,
inviteUser,
listAdminRooms,
listAdminUsers,
listAllEventSubscriptions,
listAllIncomingWebhooks,
listAuditLog,
listSiteInvites,
promoteUser,
reactivateUser,
resetUserPassword,
revokeSiteInvite,
sendTestSmtpEmail,
transferOwnershipAdmin,
unarchiveRoom,
updateSmtpSettings,
} from '../api/admin'
import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
@@ -28,6 +34,8 @@ import type {
AuditLogEntry,
Bot,
EventSubscriptionAdmin,
SiteInvite,
SmtpSettings,
WebhookIncomingAdmin,
} from '../types'
import { TopBar } from '../components/TopBar'
@@ -58,6 +66,22 @@ export function AdminPage() {
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncomingAdmin[]>([])
const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscriptionAdmin[]>([])
const [siteInvites, setSiteInvites] = useState<SiteInvite[]>([])
const [inviteEmail, setInviteEmail] = useState('')
const [invitingBusy, setInvitingBusy] = useState(false)
const [smtpSettings, setSmtpSettings] = useState<SmtpSettings | null>(null)
const [smtpLoaded, setSmtpLoaded] = useState(false)
const [smtpHost, setSmtpHost] = useState('')
const [smtpPort, setSmtpPort] = useState('587')
const [smtpUsername, setSmtpUsername] = useState('')
const [smtpPassword, setSmtpPassword] = useState('')
const [smtpFromAddress, setSmtpFromAddress] = useState('')
const [smtpUseTls, setSmtpUseTls] = useState(true)
const [smtpSaving, setSmtpSaving] = useState(false)
const [smtpTestBusy, setSmtpTestBusy] = useState(false)
const [smtpTestResult, setSmtpTestResult] = useState<string | null>(null)
function reportError(err: unknown) {
setError(err instanceof ApiError ? err.message : String(err))
}
@@ -88,8 +112,31 @@ export function AdminPage() {
listAllEventSubscriptions().then(setEventSubscriptions).catch(reportError)
}
function loadSiteInvites() {
listSiteInvites().then(setSiteInvites).catch(reportError)
}
function loadSmtpSettings() {
getSmtpSettings()
.then((cfg) => {
setSmtpSettings(cfg)
setSmtpLoaded(true)
if (cfg) {
setSmtpHost(cfg.host)
setSmtpPort(String(cfg.port))
setSmtpUsername(cfg.username ?? '')
setSmtpFromAddress(cfg.from_address)
setSmtpUseTls(cfg.use_tls)
}
})
.catch(reportError)
}
useEffect(() => {
if (tab === 'users') loadUsers()
if (tab === 'users') {
loadUsers()
loadSiteInvites()
}
if (tab === 'rooms') {
loadRooms()
if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer
@@ -99,6 +146,7 @@ export function AdminPage() {
loadWebhooksAdmin()
}
if (tab === 'audit') loadAuditLog()
if (tab === 'settings') loadSmtpSettings()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab])
@@ -210,6 +258,64 @@ export function AdminPage() {
})
}
async function handleInviteUser() {
const email = inviteEmail.trim()
if (!email) return
setInvitingBusy(true)
setError(null)
try {
await inviteUser(email)
setInviteEmail('')
loadSiteInvites()
} catch (err) {
reportError(err)
} finally {
setInvitingBusy(false)
}
}
async function handleRevokeSiteInvite(invite: SiteInvite) {
await withBusy(invite.id, async () => {
const updated = await revokeSiteInvite(invite.id)
setSiteInvites((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))
})
}
async function handleSaveSmtpSettings(e: FormEvent) {
e.preventDefault()
setSmtpSaving(true)
setError(null)
try {
const updated = await updateSmtpSettings({
host: smtpHost.trim(),
port: Number(smtpPort),
username: smtpUsername.trim() || null,
password: smtpPassword || undefined,
from_address: smtpFromAddress.trim(),
use_tls: smtpUseTls,
})
setSmtpSettings(updated)
setSmtpPassword('')
} catch (err) {
reportError(err)
} finally {
setSmtpSaving(false)
}
}
async function handleSendTestEmail() {
setSmtpTestBusy(true)
setSmtpTestResult(null)
try {
await sendTestSmtpEmail()
setSmtpTestResult('Test email sent — check your inbox.')
} catch (err) {
setSmtpTestResult(err instanceof ApiError ? err.message : String(err))
} finally {
setSmtpTestBusy(false)
}
}
return (
<div className="admin-page">
<TopBar />
@@ -243,6 +349,49 @@ export function AdminPage() {
{error && <p className="admin-error">{error}</p>}
{tab === 'users' && (
<>
<div className="admin-create-form">
<input
type="email"
placeholder="Email address to invite"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<button
type="button"
className="btn-secondary"
disabled={invitingBusy || !inviteEmail.trim()}
onClick={handleInviteUser}
>
{invitingBusy ? 'Sending…' : 'Send invite'}
</button>
</div>
{siteInvites.length > 0 && (
<div className="admin-token-list">
{siteInvites.map((invite) => (
<div key={invite.id} className="admin-token-row">
<span className="admin-token-scopes">{invite.email}</span>
<span className="admin-token-meta">
{invite.status}
{invite.status === 'pending' &&
` · expires ${new Date(invite.expires_at).toLocaleDateString()}`}
</span>
{invite.status === 'pending' && (
<button
type="button"
className="admin-token-revoke"
disabled={busyId === invite.id}
onClick={() => handleRevokeSiteInvite(invite)}
>
Revoke
</button>
)}
</div>
))}
</div>
)}
<table className="admin-table">
<thead>
<tr>
@@ -300,6 +449,7 @@ export function AdminPage() {
))}
</tbody>
</table>
</>
)}
{tab === 'rooms' && (
@@ -523,9 +673,88 @@ export function AdminPage() {
)}
{tab === 'settings' && (
<p className="admin-placeholder">
System settings are coming in a future phase — there's nothing configurable yet.
</p>
<>
<h2 className="admin-subheading">SMTP (outgoing email)</h2>
{!smtpLoaded && <p className="admin-placeholder">Loading…</p>}
{smtpLoaded && (
<form className="admin-settings-form" onSubmit={handleSaveSmtpSettings}>
<div className="admin-settings-row">
<label className="admin-settings-field">
Host
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.example.com"
required
/>
</label>
<label className="admin-settings-field admin-settings-field-narrow">
Port
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
min={1}
max={65535}
required
/>
</label>
</div>
<div className="admin-settings-row">
<label className="admin-settings-field">
Username
<input
type="text"
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
/>
</label>
<label className="admin-settings-field">
Password
<input
type="password"
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={smtpSettings?.has_password ? 'Leave blank to keep current' : ''}
/>
</label>
</div>
<label className="admin-settings-field">
From address
<input
type="email"
value={smtpFromAddress}
onChange={(e) => setSmtpFromAddress(e.target.value)}
placeholder="noreply@example.com"
required
/>
</label>
<label className="admin-settings-checkbox">
<input
type="checkbox"
checked={smtpUseTls}
onChange={(e) => setSmtpUseTls(e.target.checked)}
/>
Use TLS
</label>
<div className="admin-settings-actions">
<button type="submit" className="btn-primary" disabled={smtpSaving}>
{smtpSaving ? 'Saving' : 'Save'}
</button>
<button
type="button"
className="btn-secondary"
disabled={smtpTestBusy || !smtpSettings}
onClick={handleSendTestEmail}
>
{smtpTestBusy ? 'Sending' : 'Send test email'}
</button>
</div>
{smtpTestResult && <p className="admin-settings-test-result">{smtpTestResult}</p>}
</form>
)}
</>
)}
</div>
</div>
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState, type FormEvent } from 'react'
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
import { ApiError } from '../api/client'
import { completeSignup, validateSignupToken } from '../api/signup'
import { useAuth } from '../context/AuthContext'
import logo from '../assets/logo.png'
import './LoginPage.css'
export function SignupPage() {
const { user, updateUser } = useAuth()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get('token') ?? ''
const [checking, setChecking] = useState(true)
const [email, setEmail] = useState<string | null>(null)
const [validationError, setValidationError] = useState<string | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!token) {
setValidationError('This invite link is missing a token.')
setChecking(false)
return
}
validateSignupToken(token)
.then((result) => setEmail(result.email))
.catch((err) => {
setValidationError(err instanceof ApiError ? err.message : 'This invite link is invalid.')
})
.finally(() => setChecking(false))
}, [token])
if (user) return <Navigate to="/rooms" replace />
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const newUser = await completeSignup(token, username, password)
updateUser(newUser)
navigate('/rooms')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Something went wrong')
} finally {
setSubmitting(false)
}
}
return (
<div className="login-screen">
<div className="login-card">
<div className="login-brand">
<img src={logo} alt="" />
<span>KeepItTalking</span>
</div>
{checking && <p className="login-copy">Checking your invite</p>}
{!checking && validationError && (
<>
<p className="login-copy">{validationError}</p>
<p className="login-copy">Ask whoever invited you to send a new invite.</p>
</>
)}
{!checking && !validationError && (
<>
<p className="login-copy">Set up your account for {email}.</p>
<form className="login-form" onSubmit={handleSubmit}>
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={50}
autoFocus
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
{error && <p className="login-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
Create account
</button>
</form>
</>
)}
</div>
</div>
)
}
+19
View File
@@ -206,3 +206,22 @@ export interface EventSubscriptionAdmin extends EventSubscription {
room_name: string | null
created_by_username: string
}
export interface SiteInvite {
id: string
email: string
invited_by: string
status: InviteStatus
expires_at: string
created_at: string
}
export interface SmtpSettings {
host: string
port: number
username: string | null
has_password: boolean
from_address: string
use_tls: boolean
updated_at: string
}