feat(shard): admin-configurable visibility for every shard surface
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which
was the entire public/admin boundary - with per-feature, per-field
audience control an admin owns from Admin -> Shard Visibility.
Closes a live leak. BridgeJson.Actor() writes acct and webId;
shapeGuild() returned the stored payload verbatim; GET
/api/v1/public/shard/guilds is anonymous. Guild leaders' game account
names and website user ids were readable by anyone, and the same path
existed for governors. Both are now projected.
The ladder is anonymous < logged_in < player < staff < admin, each rung
implying the ones below. Staff satisfy `player` without a linked account
(as /player/* already does); `editor` is a content role and gets no
shard privilege, since mapping it to staff would silently widen what
editors see.
Two invariants are code, not configuration, and both reject rather than
silently ignore:
1. acct/webId are admin-only always - not configurable, discarded on
read as well as rejected on write.
2. A kind absent from KIND_FEATURE never reaches anyone below admin.
Fail closed, so a shard emitting a new event degrades to staff-only
rather than to public.
Enforcement is three points over one config: requireFeature() on routes
(404 disabled, 403 out-of-rung) plus field projection; per-connection
filtering on SSE, where a subscriber's rung is resolved once at subscribe
time and frozen so a long-open stream cannot gain privilege; and
/public/shard/features so the SPA hides links it cannot follow.
PUBLIC_KINDS still exists and is still exported (/feed filtering,
notificationStreams) but is now derived from the kind map, so the two
can no longer drift. Defaults reproduce pre-3.0 behavior exactly - a
test pins the derived set against the old allowlist.
Also fixes an SSE resource leak found while testing: a client dropped
because its write threw was removed from the bucket but its keepalive
interval was never cleared, firing forever on a dead socket. Both paths
now go through one drop().
Tests: 478 server (33 new across shardVisibility + shardBroadcast),
43 client. Route manifest and OpenAPI spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
||||
import ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
|
||||
import ShardOps from './routes/admin/views/ShardOps.jsx'
|
||||
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||
@@ -142,6 +143,7 @@ export default function App() {
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||
<Route
|
||||
path="shard-ops"
|
||||
element={
|
||||
|
||||
@@ -147,6 +147,9 @@ export const api = {
|
||||
},
|
||||
presence: () => req('/public/shard/presence'),
|
||||
houses: () => req('/public/shard/houses'),
|
||||
// Which shard surfaces this caller may reach, plus the audience rung they
|
||||
// resolved to. Drives nav so we never render a link that would 403.
|
||||
features: () => req('/public/shard/features'),
|
||||
},
|
||||
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
||||
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||
@@ -346,6 +349,13 @@ export const api = {
|
||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
// Per-feature shard visibility: who may see which shard surface, and which
|
||||
// sensitive fields within it. Admin only — it decides what ANONYMOUS
|
||||
// visitors get. acct/webId are admin-only always and the API rejects any
|
||||
// attempt to configure them.
|
||||
getShardVisibility: () => req('/admin/shard/visibility'),
|
||||
saveShardVisibility: (features) =>
|
||||
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
||||
|
||||
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
|
||||
@@ -2,9 +2,15 @@ import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||
|
||||
// One consistent top nav for the whole public site. Every page gets the same
|
||||
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||
//
|
||||
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
|
||||
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
||||
// viewer can't reach them, so we never render a link that would 403. The gate
|
||||
// itself is server-side; this is only about not advertising a dead end.
|
||||
const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
@@ -12,11 +18,11 @@ const NAV = [
|
||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard' },
|
||||
{ label: 'Champions', to: '/site/champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds' },
|
||||
{ label: 'Governors', to: '/site/governors' },
|
||||
{ label: 'Houses', to: '/site/houses' },
|
||||
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
@@ -29,6 +35,8 @@ const linkStyle = ({ isActive }) => ({
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
let account
|
||||
@@ -60,7 +68,7 @@ export default function SiteHeader() {
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{NAV.map((l) => (
|
||||
{nav.map((l) => (
|
||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
|
||||
58
client/src/lib/useShardFeatures.js
Normal file
58
client/src/lib/useShardFeatures.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// Which shard surfaces the current viewer may reach, from
|
||||
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
|
||||
// Visibility), so the nav can't be a static list any more.
|
||||
//
|
||||
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
|
||||
// and an out-of-rung one 403s whether or not the link is rendered. So while the
|
||||
// answer is still in flight we return `null` and callers show their default set
|
||||
// — better a link that briefly 403s than a nav that flickers in on every load.
|
||||
//
|
||||
// Cached module-level: the answer is per-viewer but stable for a session, and
|
||||
// every consumer would otherwise refetch it on mount.
|
||||
let cached = null
|
||||
let inFlight = null
|
||||
|
||||
export function resetShardFeatures() {
|
||||
cached = null
|
||||
inFlight = null
|
||||
}
|
||||
|
||||
export function useShardFeatures() {
|
||||
const [features, setFeatures] = useState(cached)
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return undefined
|
||||
let alive = true
|
||||
inFlight =
|
||||
inFlight ||
|
||||
api.shard
|
||||
.features()
|
||||
.then((data) => {
|
||||
cached = { level: data.level, set: new Set(data.features || []) }
|
||||
return cached
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed lookup must not blank the nav — fall back to "show
|
||||
// everything" and let the server do the gating.
|
||||
cached = null
|
||||
inFlight = null
|
||||
return null
|
||||
})
|
||||
inFlight.then((result) => {
|
||||
if (alive) setFeatures(result)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
// Convenience: true when `name` is visible, or when we don't know yet.
|
||||
export function canSee(features, name) {
|
||||
return !features || features.set.has(name)
|
||||
}
|
||||
@@ -78,6 +78,7 @@ const NAV = [
|
||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
@@ -106,6 +107,7 @@ const TITLES = {
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
'/admin/discord-bot': 'Discord Bot',
|
||||
'/admin/shard': 'Shard (uo-link)',
|
||||
'/admin/shard-visibility': 'Shard Visibility',
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
|
||||
318
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
318
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// ── Admin · Shard visibility ────────────────────────────────────────────────
|
||||
//
|
||||
// Who may see which shard surface, and which sensitive fields within it.
|
||||
// Admin-only, because this decides what ANONYMOUS visitors get.
|
||||
//
|
||||
// Two things the UI must communicate honestly, because they are not negotiable
|
||||
// server-side (see docs/link/v3.md §3.4):
|
||||
// • acct / webId are admin-only always and are not listed as editable fields.
|
||||
// • an event kind the server doesn't know about never reaches anyone below
|
||||
// admin, whatever is set here.
|
||||
//
|
||||
// Defaults reproduce the behavior the site had before this panel existed, so a
|
||||
// fresh install shows "everything as it was" rather than an empty form.
|
||||
|
||||
const RUNG_LABEL = {
|
||||
anonymous: 'Everyone',
|
||||
logged_in: 'Signed in',
|
||||
player: 'Linked players',
|
||||
staff: 'Staff',
|
||||
admin: 'Admins only',
|
||||
}
|
||||
|
||||
const RUNG_HINT = {
|
||||
anonymous: 'Visible to anyone, signed in or not.',
|
||||
logged_in: 'Any signed-in account, linked or not.',
|
||||
player: 'Accounts with a linked game account. Staff always qualify.',
|
||||
staff: 'Admins and moderators.',
|
||||
admin: 'Admins only.',
|
||||
}
|
||||
|
||||
const FEATURE_LABEL = {
|
||||
status: 'Shard status',
|
||||
activity: 'Activity feed',
|
||||
champs: 'Champion spawns',
|
||||
guilds: 'Guilds',
|
||||
governors: 'Town governors',
|
||||
houses: 'Houses / IDOC',
|
||||
presence: 'Players online',
|
||||
ruleset: 'Shard rules',
|
||||
atlas: 'Spawn atlas',
|
||||
leaderboards: 'Leaderboards',
|
||||
market: 'Marketplace',
|
||||
}
|
||||
|
||||
const FEATURE_HINT = {
|
||||
status: 'Connection state, online count, gold-supply series.',
|
||||
activity: 'Deaths, kills, skill gains, quests, logins.',
|
||||
champs: 'The live champion / mini-champ / sea-boss board.',
|
||||
guilds: 'Guild rosters, alliances and leaders.',
|
||||
governors: 'City Loyalty governors, elections and term history.',
|
||||
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
|
||||
presence: 'Population aggregate and the staff-online widget.',
|
||||
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
|
||||
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
|
||||
leaderboards: 'Point and loyalty standings across every points system.',
|
||||
market: 'The shard-wide player-vendor index.',
|
||||
}
|
||||
|
||||
const FIELD_LABEL = {
|
||||
owner: 'House owner',
|
||||
price: 'House price',
|
||||
location: 'In-game location (map + coordinates)',
|
||||
connect: 'Server connect address',
|
||||
characterName: 'Character names',
|
||||
ownerName: 'Vendor owner name',
|
||||
}
|
||||
|
||||
function RungSelect({ value, onChange, ladder, disabled }) {
|
||||
return (
|
||||
<select
|
||||
className="input"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ maxWidth: 200 }}
|
||||
>
|
||||
{ladder.map((rung) => (
|
||||
<option key={rung} value={rung}>
|
||||
{RUNG_LABEL[rung] || rung}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
|
||||
const fields = Object.entries(settings.fields || {})
|
||||
const changed =
|
||||
defaults &&
|
||||
(settings.enabled !== defaults.enabled ||
|
||||
settings.audience !== defaults.audience ||
|
||||
settings.stream !== defaults.stream ||
|
||||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
opacity: settings.enabled ? 1 : 0.62,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{FEATURE_LABEL[name] || name}
|
||||
{changed && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
|
||||
>
|
||||
changed
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||
{FEATURE_HINT[name]}
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
className="sans"
|
||||
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Who can see it</span>
|
||||
<RungSelect
|
||||
value={settings.audience}
|
||||
ladder={ladder}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(audience) => onPatch(name, { audience })}
|
||||
/>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
|
||||
{RUNG_HINT[settings.audience]}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className="sans"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.stream}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(e) => onPatch(name, { stream: e.target.checked })}
|
||||
/>
|
||||
Live updates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
|
||||
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Sensitive fields
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||
{fields.map(([field, rung]) => (
|
||||
<label key={field} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
|
||||
{FIELD_LABEL[field] || field}
|
||||
</span>
|
||||
<RungSelect
|
||||
value={rung}
|
||||
ladder={ladder}
|
||||
disabled={!settings.enabled}
|
||||
onChange={(level) =>
|
||||
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardVisibility() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [defaults, setDefaults] = useState(null)
|
||||
const [ladder, setLadder] = useState([])
|
||||
const [lockedFields, setLockedFields] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.admin.getShardVisibility()
|
||||
setConfig(data.features)
|
||||
setDefaults(data.defaults)
|
||||
setLadder(data.ladder || [])
|
||||
setLockedFields(data.lockedFields || [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load visibility settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
function patch(name, changes) {
|
||||
setMsg('')
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev[name], ...changes }
|
||||
// `fieldRules` in the API is `fields` in the effective config.
|
||||
if (changes.fieldRules) {
|
||||
next.fields = changes.fieldRules
|
||||
delete next.fieldRules
|
||||
}
|
||||
return { ...prev, [name]: next }
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const body = {}
|
||||
for (const [name, s] of Object.entries(config)) {
|
||||
body[name] = {
|
||||
enabled: s.enabled,
|
||||
audience: s.audience,
|
||||
stream: s.stream,
|
||||
fieldRules: s.fields || {},
|
||||
}
|
||||
}
|
||||
const data = await api.admin.saveShardVisibility(body)
|
||||
setConfig(data.features)
|
||||
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
setMsg('')
|
||||
setConfig(structuredClone(defaults))
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !config) return <ErrorState message={error} onRetry={load} />
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Shard visibility
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Choose who can see each shard surface on the public site, and how much detail they get.
|
||||
Turning a feature off hides it entirely — its pages return “not found” rather than
|
||||
revealing that it exists. “Live updates” controls whether the feature streams changes in
|
||||
real time; the pages still work without it, they just refresh on load.
|
||||
</p>
|
||||
{lockedFields.length > 0 && (
|
||||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
|
||||
game account names and website user ids are never shown below admin, on any surface. They
|
||||
aren’t visible in game either, so publishing them would disclose something the shard
|
||||
itself doesn’t.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{Object.entries(config).map(([name, settings]) => (
|
||||
<FeatureRow
|
||||
key={name}
|
||||
name={name}
|
||||
settings={settings}
|
||||
defaults={defaults?.[name]}
|
||||
ladder={ladder}
|
||||
onPatch={patch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
|
||||
Restore defaults
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user