mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-28 00:18:58 -07:00
[www] tweak permissions
This commit is contained in:
+128
-122
@@ -20,6 +20,7 @@ import {
|
||||
call,
|
||||
hasToken,
|
||||
isAdmin,
|
||||
isDeveloper,
|
||||
setHosts,
|
||||
setToken,
|
||||
useAction,
|
||||
@@ -1184,129 +1185,134 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Staff</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void gift.run(async () => {
|
||||
const amount = Number(tokens)
|
||||
const res = await call<{ balance: number }>(`${base}/gift-tokens`, {
|
||||
authed: true,
|
||||
json: { amount },
|
||||
})
|
||||
setTokens('')
|
||||
const what =
|
||||
amount < 0
|
||||
? `Took ${Math.abs(amount).toLocaleString()} tokens back`
|
||||
: amount === 0
|
||||
? 'Sent an empty box'
|
||||
: `Sent ${amount.toLocaleString()} tokens`
|
||||
return `${what}. @${account.username} now has ${res.balance.toLocaleString()}.`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift tokens
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="number"
|
||||
step={1}
|
||||
value={tokens}
|
||||
required
|
||||
onChange={(e) => setTokens(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={gift.pending}>
|
||||
{gift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box, and is added to their balance right away. A negative amount takes
|
||||
tokens back instead — it still sends a box, and it can't take more than they have.
|
||||
Zero sends an empty box and moves nothing.
|
||||
</span>
|
||||
</label>
|
||||
{gift.error && <p className="error">{gift.error}</p>}
|
||||
{gift.done && <p className="ok">{gift.done}</p>}
|
||||
</form>
|
||||
{/* The gifts are developer-only; www refuses them to moderators. */}
|
||||
{isDeveloper() && (
|
||||
<>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void gift.run(async () => {
|
||||
const amount = Number(tokens)
|
||||
const res = await call<{ balance: number }>(`${base}/gift-tokens`, {
|
||||
authed: true,
|
||||
json: { amount },
|
||||
})
|
||||
setTokens('')
|
||||
const what =
|
||||
amount < 0
|
||||
? `Took ${Math.abs(amount).toLocaleString()} tokens back`
|
||||
: amount === 0
|
||||
? 'Sent an empty box'
|
||||
: `Sent ${amount.toLocaleString()} tokens`
|
||||
return `${what}. @${account.username} now has ${res.balance.toLocaleString()}.`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift tokens
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="number"
|
||||
step={1}
|
||||
value={tokens}
|
||||
required
|
||||
onChange={(e) => setTokens(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={gift.pending}>
|
||||
{gift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box, and is added to their balance right away. A negative amount
|
||||
takes tokens back instead — it still sends a box, and it can't take more than
|
||||
they have. Zero sends an empty box and moves nothing.
|
||||
</span>
|
||||
</label>
|
||||
{gift.error && <p className="error">{gift.error}</p>}
|
||||
{gift.done && <p className="ok">{gift.done}</p>}
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void xpGift.run(async () => {
|
||||
const amount = Number(xp)
|
||||
const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, {
|
||||
authed: true,
|
||||
json: { amount },
|
||||
})
|
||||
setXp('')
|
||||
const levels =
|
||||
res.levelsGained > 0
|
||||
? ` They went up ${res.levelsGained} level${res.levelsGained === 1 ? '' : 's'}, to ${res.level}.`
|
||||
: ` They're level ${res.level}.`
|
||||
return `Sent ${amount.toLocaleString()} XP.${levels}`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift XP
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={xp}
|
||||
required
|
||||
onChange={(e) => setXp(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={xpGift.pending}>
|
||||
{xpGift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box. Levels it crosses count, but don't pay their level-up
|
||||
rewards.
|
||||
</span>
|
||||
</label>
|
||||
{xpGift.error && <p className="error">{xpGift.error}</p>}
|
||||
{xpGift.done && <p className="ok">{xpGift.done}</p>}
|
||||
</form>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void xpGift.run(async () => {
|
||||
const amount = Number(xp)
|
||||
const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, {
|
||||
authed: true,
|
||||
json: { amount },
|
||||
})
|
||||
setXp('')
|
||||
const levels =
|
||||
res.levelsGained > 0
|
||||
? ` They went up ${res.levelsGained} level${res.levelsGained === 1 ? '' : 's'}, to ${res.level}.`
|
||||
: ` They're level ${res.level}.`
|
||||
return `Sent ${amount.toLocaleString()} XP.${levels}`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift XP
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={xp}
|
||||
required
|
||||
onChange={(e) => setXp(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={xpGift.pending}>
|
||||
{xpGift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box. Levels it crosses count, but don't pay their level-up
|
||||
rewards.
|
||||
</span>
|
||||
</label>
|
||||
{xpGift.error && <p className="error">{xpGift.error}</p>}
|
||||
{xpGift.done && <p className="ok">{xpGift.done}</p>}
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void customGift.run(async () => {
|
||||
const res = await call<{ name: string }>(`${base}/gift-custom-item`, {
|
||||
authed: true,
|
||||
json: { customAvatarItemId: customItemId.trim() },
|
||||
})
|
||||
setCustomItemId('')
|
||||
return `Sent “${res.name}” to @${account.username}.`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift custom item
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="text"
|
||||
value={customItemId}
|
||||
placeholder="Custom avatar item id"
|
||||
spellCheck={false}
|
||||
required
|
||||
onChange={(e) => setCustomItemId(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={customGift.pending}>
|
||||
{customGift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box, as if bought from the store. Free to them, and the creator
|
||||
isn't paid.
|
||||
</span>
|
||||
</label>
|
||||
{customGift.error && <p className="error">{customGift.error}</p>}
|
||||
{customGift.done && <p className="ok">{customGift.done}</p>}
|
||||
</form>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void customGift.run(async () => {
|
||||
const res = await call<{ name: string }>(`${base}/gift-custom-item`, {
|
||||
authed: true,
|
||||
json: { customAvatarItemId: customItemId.trim() },
|
||||
})
|
||||
setCustomItemId('')
|
||||
return `Sent “${res.name}” to @${account.username}.`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift custom item
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="text"
|
||||
value={customItemId}
|
||||
placeholder="Custom avatar item id"
|
||||
spellCheck={false}
|
||||
required
|
||||
onChange={(e) => setCustomItemId(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={customGift.pending}>
|
||||
{customGift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box, as if bought from the store. Free to them, and the creator
|
||||
isn't paid.
|
||||
</span>
|
||||
</label>
|
||||
{customGift.error && <p className="error">{customGift.error}</p>}
|
||||
{customGift.done && <p className="ok">{customGift.done}</p>}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mod-filter-actions staff-actions">
|
||||
<button
|
||||
@@ -2161,7 +2167,7 @@ function PublicRoomView({
|
||||
role itself on the DELETE. */}
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<StaffRoomTokens room={room} />
|
||||
{isDeveloper() && <StaffRoomTokens room={room} />}
|
||||
<StaffTakedown room={room} onTakenDown={onTakenDown} />
|
||||
</>
|
||||
)}
|
||||
|
||||
+23
-10
@@ -78,22 +78,35 @@ export function where(): Hosts {
|
||||
*/
|
||||
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||
|
||||
/** The session token's `role` claim, decoded WITHOUT verifying it. Malformed reads as none. */
|
||||
function tokenRoles(): string[] {
|
||||
const payload = token?.split('.')[1]
|
||||
if (!payload) return []
|
||||
try {
|
||||
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
||||
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
||||
return Array.isArray(claims.role) ? (claims.role as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
|
||||
* verifying it — a page holds no signing key, and faking one here only reveals buttons
|
||||
* whose endpoints reject the same token. A malformed token reads as "not admin".
|
||||
*/
|
||||
export function isAdmin(): boolean {
|
||||
const payload = token?.split('.')[1]
|
||||
if (!payload) return false
|
||||
try {
|
||||
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
||||
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
||||
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return tokenRoles().some((r) => ADMIN_ROLES.has(r))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session token carries the `developer` role — the staff gifts are narrowed to
|
||||
* it (www's `requireDeveloper`). Cosmetic in the same way as {@link isAdmin}.
|
||||
*/
|
||||
export function isDeveloper(): boolean {
|
||||
return tokenRoles().includes('developer')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user