R18's two tiers: a form generated from a config file's own values, and raw JSON for what a form cannot express. Admin → Rust mod config, one live round trip per action, nothing cached between a browser and a game host's disk. `configEdit.js` is the part that could not be done naively. JavaScript cannot tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C# classes — so a read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that then throws at load does not come back. It never parses, mutates and re-serialises: it records the SOURCE SPAN of every value and splices literals into them, so an untouched `1.0` is still `1.0` and a number an admin types travels as text the whole way (D35/D36). The bridge's own config is editable with `Host`, `Port` and `ServerId` locked, in the form and in the raw tier, because either would cut the link carrying the edit or strand every row this site holds (D38). Credentials render masked with a reveal; the raw tier shows them (D37) and the audit trail never does. `rust_config_writes` records every save including the refused and the rolled back — an operator asking why a setting is not what they set needs to see that somebody tried. Three defects a browser walk found that 179 green tests did not: * every save of the bridge's own config was refused while the page said the opposite — a `<select>` whose value matches no `<option>` shows the first one, so the reload guess `RunicGateway` was on the wire and "nothing" was on the screen; * `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every secondary button in this module has rendered as a primary one since phase 7 — here it made the open file and the active tier indistinguishable; * a save's refusal rendered at the top of a long form, far from the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
618 lines
21 KiB
JavaScript
618 lines
21 KiB
JavaScript
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||
//
|
||
// R2's authoring surface, and this module's first admin page.
|
||
//
|
||
// **What is on it is decided by what an operator can get wrong**, rather than by
|
||
// what the tables contain. Four states are invisible from the game and from a
|
||
// list of grants, and every one of them looks exactly like success:
|
||
//
|
||
// • a grant against somebody who has linked no Steam account — authored,
|
||
// stored, pushed nowhere;
|
||
// • a permission no loaded plugin has registered — the grant lands silently
|
||
// nowhere, because `GrantUserPermission` no-ops for an unregistered name;
|
||
// • a group member who has never connected — the store has no user record to
|
||
// put in a group yet, and the membership waits for their first connection;
|
||
// • a server whose last sync failed — the site is authoritative and the game
|
||
// has not heard it.
|
||
//
|
||
// So each of those is a sentence on this page rather than a number in a report.
|
||
//
|
||
// The screen never writes to a game. Every button here writes to the site and
|
||
// the mirror's loop reconciles within seconds — except *Sync now*, which runs
|
||
// that pass immediately because an operator who has just changed something
|
||
// should not have to trust a timer to find out that a host is unreachable.
|
||
|
||
import { useCallback, useState } from 'react'
|
||
|
||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||
import { ago } from '../../lib/format.js'
|
||
import api from '../../api.js'
|
||
|
||
const FLEET = '*'
|
||
|
||
/** Shared furniture. The kit is nine exports and none of them is a table. */
|
||
function Card({ title, subtitle, children, actions }) {
|
||
return (
|
||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||
{title}
|
||
</h2>
|
||
{subtitle && (
|
||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||
{subtitle}
|
||
</span>
|
||
)}
|
||
<span style={{ flex: 1 }} />
|
||
{actions}
|
||
</header>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function Row({ children, muted = false }) {
|
||
return (
|
||
<div
|
||
className="sans"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
padding: '8px 0',
|
||
borderTop: '1px solid var(--line-soft)',
|
||
fontSize: '0.86rem',
|
||
color: muted ? 'var(--ink)' : 'var(--head)',
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Warn({ children }) {
|
||
return (
|
||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
|
||
{children}
|
||
</p>
|
||
)
|
||
}
|
||
|
||
function Scope({ value }) {
|
||
return (
|
||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||
{value === FLEET ? 'every server' : value}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* One server's mirror state.
|
||
*
|
||
* `unresolved` and `pending` are rendered as sentences rather than counts
|
||
* because each is a different problem with a different fix, and both are
|
||
* invisible everywhere else on this page.
|
||
*/
|
||
function ServerState({ row, onSync, busy }) {
|
||
const report = row.report || {}
|
||
const unresolved = report.unresolved || []
|
||
const pending = report.pending || []
|
||
|
||
return (
|
||
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||
{row.serverId}
|
||
</span>
|
||
<span
|
||
className="sans"
|
||
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
|
||
>
|
||
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
|
||
</span>
|
||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
|
||
</span>
|
||
<span style={{ flex: 1 }} />
|
||
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
|
||
{busy ? 'Syncing…' : 'Sync now'}
|
||
</button>
|
||
</div>
|
||
|
||
{row.error && (
|
||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||
{row.error}
|
||
</p>
|
||
)}
|
||
|
||
{unresolved.length > 0 && (
|
||
<Warn>
|
||
{unresolved.join(', ')} — no plugin loaded on this server has registered{' '}
|
||
{unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '}
|
||
{unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when
|
||
the plugin is back.
|
||
</Warn>
|
||
)}
|
||
|
||
{pending.length > 0 && (
|
||
<Warn>
|
||
{pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a
|
||
first connection — this server has never seen those players, so it has no account to put
|
||
in a group yet.
|
||
</Warn>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** A hand edit, with the two answers to it. */
|
||
function DriftRow({ row, onAdopt, onRevoke, busy }) {
|
||
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
|
||
|
||
return (
|
||
<Row>
|
||
<span style={{ minWidth: 0, flex: 1 }}>
|
||
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
|
||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
|
||
{row.serverId} · seen {ago(row.firstSeen)}
|
||
</span>
|
||
</span>
|
||
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||
Adopt
|
||
</button>
|
||
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||
Revoke
|
||
</button>
|
||
</Row>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The memberships the game could not place yet, as `steamId:group`.
|
||
*
|
||
* Read out of each server's own report, because it is the only thing that knows:
|
||
* a member who has never connected to a server has no user record there to put
|
||
* in a group (§12.2 rule 4), and from every other angle they look like a member.
|
||
* The server strip says how many; this is what puts it next to the person.
|
||
*/
|
||
function pendingSet(servers) {
|
||
const pending = new Map()
|
||
|
||
for (const server of servers) {
|
||
for (const entry of (server.report && server.report.pending) || []) {
|
||
if (!pending.has(entry)) pending.set(entry, [])
|
||
pending.get(entry).push(server.serverId)
|
||
}
|
||
}
|
||
|
||
return pending
|
||
}
|
||
|
||
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
|
||
const [busy, setBusy] = useState(false)
|
||
const [member, setMember] = useState('')
|
||
const [permission, setPermission] = useState('')
|
||
|
||
const act = async (fn) => {
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
await fn()
|
||
await onChanged()
|
||
} catch (err) {
|
||
setError(err.message || 'That did not work.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const save = (permissions) =>
|
||
act(() =>
|
||
api.adminPermissions.saveGroup(group.name, {
|
||
title: group.title,
|
||
rank: group.rank,
|
||
scope: group.scope,
|
||
permissions,
|
||
}),
|
||
)
|
||
|
||
return (
|
||
<Card
|
||
title={group.title || group.name}
|
||
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
|
||
actions={
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
disabled={busy}
|
||
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
|
||
>
|
||
Delete
|
||
</button>
|
||
}
|
||
>
|
||
<div className="field-label">Permissions</div>
|
||
{group.permissions.length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||
This group carries nothing, so being in it does nothing.
|
||
</p>
|
||
)}
|
||
{group.permissions.map((perm) => (
|
||
<Row key={perm}>
|
||
<span style={{ flex: 1 }}>{perm}</span>
|
||
{!catalogue.some((entry) => entry.permission === perm) && (
|
||
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
|
||
no server has registered this
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
disabled={busy}
|
||
onClick={() => save(group.permissions.filter((p) => p !== perm))}
|
||
>
|
||
Remove
|
||
</button>
|
||
</Row>
|
||
))}
|
||
|
||
<form
|
||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
if (!permission.trim()) return
|
||
save([...group.permissions, permission.trim().toLowerCase()])
|
||
setPermission('')
|
||
}}
|
||
>
|
||
<input
|
||
list="rust-permission-names"
|
||
className="input"
|
||
placeholder="kits.vip"
|
||
value={permission}
|
||
onChange={(event) => setPermission(event.target.value)}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<button type="submit" className="btn" disabled={busy}>
|
||
Add permission
|
||
</button>
|
||
</form>
|
||
|
||
<div className="field-label" style={{ marginTop: 18 }}>
|
||
Members
|
||
</div>
|
||
{group.members.length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||
Nobody is in this group.
|
||
</p>
|
||
)}
|
||
{group.members.map((m) => {
|
||
const waiting = m.accounts
|
||
.map((account) => pending.get(`${account.steamId}:${group.name}`))
|
||
.filter(Boolean)
|
||
.flat()
|
||
|
||
return (
|
||
<Row key={m.userId}>
|
||
<span style={{ flex: 1 }}>
|
||
{m.username}
|
||
{m.accounts.length > 0 ? (
|
||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||
{' '}
|
||
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
|
||
</span>
|
||
) : (
|
||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||
{' '}
|
||
· has linked no Steam account, so this reaches nobody
|
||
</span>
|
||
)}
|
||
{waiting.length > 0 && (
|
||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||
{' '}
|
||
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
disabled={busy}
|
||
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
|
||
>
|
||
Remove
|
||
</button>
|
||
</Row>
|
||
)
|
||
})}
|
||
|
||
<form
|
||
style={{ display: 'flex', gap: 8, marginTop: 10 }}
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
if (!member.trim()) return
|
||
act(() => api.adminPermissions.addMember(group.name, member.trim()))
|
||
setMember('')
|
||
}}
|
||
>
|
||
<input
|
||
className="input"
|
||
placeholder="website username"
|
||
value={member}
|
||
onChange={(event) => setMember(event.target.value)}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<button type="submit" className="btn" disabled={busy}>
|
||
Add member
|
||
</button>
|
||
</form>
|
||
|
||
{servers.length > 1 && group.scope !== FLEET && (
|
||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||
This group exists on {group.scope} only. The other servers never receive it.
|
||
</p>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
export default function Permissions() {
|
||
const [reloads, setReloads] = useState(0)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [form, setForm] = useState({ name: '', title: '', scope: FLEET })
|
||
const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET })
|
||
|
||
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
|
||
const reload = useCallback(() => setReloads((n) => n + 1), [])
|
||
|
||
const act = async (fn) => {
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
await fn()
|
||
reload()
|
||
} catch (err) {
|
||
setError(err.message || 'That did not work.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
if (loadError) return <ErrorState error={loadError} />
|
||
if (!data) return <Loading />
|
||
|
||
const servers = data.servers || []
|
||
|
||
return (
|
||
<div style={{ maxWidth: 900 }}>
|
||
{/* No heading of our own: core's admin chrome already draws the route's
|
||
title above the page, and a second one is the same words twice. */}
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||
This site is the author of record. Groups and grants written here are pushed into each
|
||
server’s own permission store, so every plugin that checks a permission honours them — and a
|
||
wipe does not lose them, because they are re-pushed when the server comes back.
|
||
</p>
|
||
|
||
{/* The option source, shared by both forms. A datalist rather than a select:
|
||
a name that no server has registered is still authorable — the plugin
|
||
may simply not be loaded right now — and the warning beside it is the
|
||
honest treatment, where a closed list would be a refusal. */}
|
||
<datalist id="rust-permission-names">
|
||
{(data.catalogue || []).map((entry) => (
|
||
<option key={entry.permission} value={entry.permission} />
|
||
))}
|
||
</datalist>
|
||
|
||
{error && (
|
||
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<Card
|
||
title="Servers"
|
||
subtitle={`${servers.length} configured`}
|
||
actions={
|
||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
|
||
Sync all
|
||
</button>
|
||
}
|
||
>
|
||
{servers.length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||
No servers are configured yet, so nothing written here reaches a game.
|
||
</p>
|
||
)}
|
||
{servers.map((row) => (
|
||
<ServerState
|
||
key={row.serverId}
|
||
row={row}
|
||
busy={busy}
|
||
onSync={(id) => act(() => api.adminPermissions.sync(id))}
|
||
/>
|
||
))}
|
||
</Card>
|
||
|
||
{(data.drift || []).length > 0 && (
|
||
<Card
|
||
title="Changed in game"
|
||
subtitle="granted at a console, not by this site"
|
||
>
|
||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
|
||
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
|
||
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
|
||
the next sync.
|
||
</p>
|
||
{data.drift.map((row) => (
|
||
<DriftRow
|
||
key={row.id}
|
||
row={row}
|
||
busy={busy}
|
||
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
|
||
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
|
||
/>
|
||
))}
|
||
</Card>
|
||
)}
|
||
|
||
<Card title="Direct grants" subtitle="one person, one permission">
|
||
{(data.grants || []).length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
|
||
Nobody holds a permission of their own yet.
|
||
</p>
|
||
)}
|
||
{(data.grants || []).map((row) => (
|
||
<Row key={row.id}>
|
||
<span style={{ flex: 1 }}>
|
||
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
|
||
<Scope value={row.scope} />
|
||
{row.accounts.length === 0 && (
|
||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||
{' '}
|
||
· has linked no Steam account, so this reaches nobody
|
||
</span>
|
||
)}
|
||
{/* The same warning the group's permission list carries, and it
|
||
matters more here: a grant naming a permission nothing has
|
||
registered is the failure the plugin's pre-check exists for,
|
||
and it is invisible on this row without it. */}
|
||
{!(data.catalogue || []).some((entry) => entry.permission === row.permission) && (
|
||
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
|
||
{' '}
|
||
· no server has registered this permission
|
||
</span>
|
||
)}
|
||
{row.source !== 'admin' && (
|
||
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
|
||
)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
disabled={busy}
|
||
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
|
||
>
|
||
Remove
|
||
</button>
|
||
</Row>
|
||
))}
|
||
|
||
<form
|
||
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
if (!grant.username.trim() || !grant.permission.trim()) return
|
||
act(() =>
|
||
api.adminPermissions.grant({
|
||
username: grant.username.trim(),
|
||
permission: grant.permission.trim().toLowerCase(),
|
||
scope: grant.scope,
|
||
}),
|
||
)
|
||
setGrant({ username: '', permission: '', scope: FLEET })
|
||
}}
|
||
>
|
||
<input
|
||
className="input"
|
||
placeholder="website username"
|
||
value={grant.username}
|
||
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
|
||
style={{ flex: '1 1 160px' }}
|
||
/>
|
||
<input
|
||
list="rust-permission-names"
|
||
className="input"
|
||
placeholder="kits.vip"
|
||
value={grant.permission}
|
||
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
|
||
style={{ flex: '1 1 160px' }}
|
||
/>
|
||
<select
|
||
className="input"
|
||
value={grant.scope}
|
||
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
|
||
>
|
||
<option value={FLEET}>every server</option>
|
||
{servers.map((row) => (
|
||
<option key={row.serverId} value={row.serverId}>
|
||
{row.serverId}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="submit" className="btn" disabled={busy}>
|
||
Grant
|
||
</button>
|
||
</form>
|
||
</Card>
|
||
|
||
{(data.groups || []).map((group) => (
|
||
<GroupCard
|
||
key={group.name}
|
||
group={group}
|
||
catalogue={data.catalogue || []}
|
||
servers={servers}
|
||
pending={pendingSet(servers)}
|
||
onChanged={reload}
|
||
setError={setError}
|
||
/>
|
||
))}
|
||
|
||
<Card title="New group">
|
||
<form
|
||
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
if (!form.name.trim()) return
|
||
act(() =>
|
||
api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), {
|
||
title: form.title.trim() || form.name.trim(),
|
||
scope: form.scope,
|
||
permissions: [],
|
||
}),
|
||
)
|
||
setForm({ name: '', title: '', scope: FLEET })
|
||
}}
|
||
>
|
||
<input
|
||
className="input"
|
||
placeholder="vip"
|
||
value={form.name}
|
||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||
style={{ flex: '1 1 140px' }}
|
||
/>
|
||
<input
|
||
className="input"
|
||
placeholder="VIP"
|
||
value={form.title}
|
||
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||
style={{ flex: '1 1 140px' }}
|
||
/>
|
||
<select
|
||
className="input"
|
||
value={form.scope}
|
||
onChange={(event) => setForm({ ...form, scope: event.target.value })}
|
||
>
|
||
<option value={FLEET}>every server</option>
|
||
{servers.map((row) => (
|
||
<option key={row.serverId} value={row.serverId}>
|
||
{row.serverId}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="submit" className="btn" disabled={busy}>
|
||
Create
|
||
</button>
|
||
</form>
|
||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||
A group is created in each in-scope game as a real group, so plugins that read group
|
||
membership see it. A member who has never connected to a server joins it there on their
|
||
first connection — a direct grant reaches them straight away, which is the difference
|
||
worth knowing when somebody is waiting.
|
||
</p>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|