Private
Public Access
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:
@@ -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={
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user