Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.
- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
row), contact recipient = contact_email setting, mailto: fallback preserved,
plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
+ PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
219 lines
10 KiB
JavaScript
219 lines
10 KiB
JavaScript
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
|
|
// same-origin API (/api/v1) — proxied to the Express server in dev.
|
|
const BASE = '/api/v1'
|
|
|
|
class ApiError extends Error {
|
|
constructor(status, message, body) {
|
|
super(message)
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
async function req(path, { method = 'GET', body, headers, raw } = {}) {
|
|
const opts = { method, credentials: 'include', headers: { ...headers } }
|
|
if (body !== undefined) {
|
|
if (raw) {
|
|
opts.body = body // FormData — let the browser set the content-type
|
|
} else {
|
|
opts.headers['Content-Type'] = 'application/json'
|
|
opts.body = JSON.stringify(body)
|
|
}
|
|
}
|
|
const res = await fetch(BASE + path, opts)
|
|
const text = await res.text()
|
|
const data = text ? safeParse(text) : null
|
|
if (!res.ok) {
|
|
const message = (data && data.message) || res.statusText || 'Request failed'
|
|
throw new ApiError(res.status, message, data)
|
|
}
|
|
return data
|
|
}
|
|
|
|
function safeParse(text) {
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return text
|
|
}
|
|
}
|
|
|
|
export const api = {
|
|
// ----- auth -----
|
|
me: () => req('/auth/me'),
|
|
// `extra` carries the honeypot field (and any future login fields).
|
|
login: (username, password, extra = {}) =>
|
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
|
// Public self-registration (player accounts). `extra` carries the honeypot +
|
|
// optional email. Returns { user } and sets the session cookie on success.
|
|
register: (username, password, extra = {}) =>
|
|
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
|
loginTotp: (challenge, code) =>
|
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
|
// the callback, so only the code is sent). Returns { user, returnTo }.
|
|
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
|
authProviders: () => req('/auth/providers'),
|
|
|
|
// ----- public -----
|
|
publicSettings: () => req('/public/settings'),
|
|
status: () => req('/public/status'),
|
|
posts: (category) => req(`/public/posts/${category}`),
|
|
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
|
|
wiki: (opts = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (opts.category) qs.set('category', opts.category)
|
|
if (opts.tag) qs.set('tag', opts.tag)
|
|
if (opts.q) qs.set('q', opts.q)
|
|
const s = qs.toString()
|
|
return req(`/public/wiki${s ? `?${s}` : ''}`)
|
|
},
|
|
wikiCategories: () => req('/public/wiki/categories'),
|
|
wikiTags: () => req('/public/wiki/tags'),
|
|
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
|
|
|
// ----- admin -----
|
|
admin: {
|
|
dashboard: () => req('/admin/dashboard'),
|
|
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
|
|
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
|
|
getPost: (id) => req(`/admin/posts/${id}`),
|
|
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
|
|
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
|
|
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
|
publishPost: (id, published) =>
|
|
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
|
uploadImage: (file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
|
|
},
|
|
// Generalized upload for rich-text editors → { url }.
|
|
upload: (file) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
|
},
|
|
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
|
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
|
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
|
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
|
|
publishWiki: (slug, published) =>
|
|
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
|
|
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
|
|
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
|
|
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
|
|
restoreWikiRevision: (slug, id) =>
|
|
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
|
|
listWikiTags: () => req('/admin/wiki/tags'),
|
|
listWikiCategories: () => req('/admin/wiki/categories'),
|
|
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
|
|
updateWikiCategory: (id, data) =>
|
|
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
|
|
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
|
getSettings: () => req('/admin/settings'),
|
|
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
|
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
|
botActivity: () => req('/admin/bot-activity'),
|
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
|
listUsers: () => req('/admin/users'),
|
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
|
|
|
// ----- moderation dashboard (admin + moderator) -----
|
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
|
modRecent: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.type) qs.set('type', params.type)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
|
},
|
|
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
|
modMembers: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.type) qs.set('type', params.type)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
|
|
},
|
|
modFilterHits: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
|
|
},
|
|
modSpamHits: (params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
|
|
},
|
|
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
|
modUserActions: (discordId, params = {}) => {
|
|
const qs = new URLSearchParams()
|
|
if (params.type) qs.set('type', params.type)
|
|
if (params.limit) qs.set('limit', params.limit)
|
|
if (params.offset) qs.set('offset', params.offset)
|
|
const s = qs.toString()
|
|
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
|
},
|
|
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
|
addModNote: (discordId, data) =>
|
|
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
|
|
|
// ----- account security (self-service 2FA) -----
|
|
getAccount: () => req('/admin/account'),
|
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
|
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
|
|
|
// ----- linked SSO identities (self-service) -----
|
|
linkedIdentities: () => req('/admin/account/identities'),
|
|
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
|
|
|
// ----- auth providers / SSO config (admin only) -----
|
|
listAuthProviders: () => req('/admin/auth/providers'),
|
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
|
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
|
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
|
|
|
// ----- Discord bot control (admin only) -----
|
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
|
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
|
|
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
|
getEmailConfig: () => req('/admin/email/config'),
|
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
|
emailConnectUrl: () => req('/admin/email/connect/start'),
|
|
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
|
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
|
},
|
|
|
|
// ----- player self-service (role: 'player') -----
|
|
// Mirrors the admin account methods but self-scoped under /player. The change
|
|
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
|
player: {
|
|
getAccount: () => req('/player/account'),
|
|
changeUsername: (username) =>
|
|
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
|
changePassword: (newPassword, currentPassword) =>
|
|
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
|
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
|
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
|
linkedIdentities: () => req('/player/account/identities'),
|
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
|
},
|
|
}
|
|
|
|
export { ApiError }
|