mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
[www] enable plus for a user
This commit is contained in:
@@ -1291,6 +1291,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
const itemGift = useAction()
|
const itemGift = useAction()
|
||||||
const usernameChange = useAction()
|
const usernameChange = useAction()
|
||||||
const clearPassword = useAction()
|
const clearPassword = useAction()
|
||||||
|
const grantPlus = useAction()
|
||||||
const base = `/api/staff/players/${account.accountId}`
|
const base = `/api/staff/players/${account.accountId}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1458,6 +1459,26 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
>
|
>
|
||||||
{usernameChange.pending ? 'Adding…' : 'Add username change'}
|
{usernameChange.pending ? 'Adding…' : 'Add username change'}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Developer-only, like the gifts; www refuses it to moderators. */}
|
||||||
|
{isDeveloper() && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={grantPlus.pending}
|
||||||
|
onClick={() =>
|
||||||
|
void grantPlus.run(async () => {
|
||||||
|
const res = await call<{ hadPlus: boolean }>(`${base}/grant-plus`, {
|
||||||
|
authed: true,
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
return res.hadPlus
|
||||||
|
? `@${account.username} already has Plus.`
|
||||||
|
: `@${account.username} now has Plus. It takes effect the next time they sign in.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{grantPlus.pending ? 'Granting…' : 'Grant Plus'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{confirmingClear ? (
|
{confirmingClear ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1509,6 +1530,8 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
{usernameChange.done && <p className="ok">{usernameChange.done}</p>}
|
{usernameChange.done && <p className="ok">{usernameChange.done}</p>}
|
||||||
{clearPassword.error && <p className="error">{clearPassword.error}</p>}
|
{clearPassword.error && <p className="error">{clearPassword.error}</p>}
|
||||||
{clearPassword.done && <p className="ok">{clearPassword.done}</p>}
|
{clearPassword.done && <p className="ok">{clearPassword.done}</p>}
|
||||||
|
{grantPlus.error && <p className="error">{grantPlus.error}</p>}
|
||||||
|
{grantPlus.done && <p className="ok">{grantPlus.done}</p>}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getPlayerIdsInRoom,
|
getPlayerIdsInRoom,
|
||||||
getPresences,
|
getPresences,
|
||||||
movePlayerToDorm,
|
movePlayerToDorm,
|
||||||
|
updateAccount,
|
||||||
writeAuditLog,
|
writeAuditLog,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { PlatformType } from '@repo/domain/src/enums'
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
@@ -1407,3 +1408,28 @@ export async function clearPasswordHandler(c: Context<App>) {
|
|||||||
logger.info('staff cleared a password', { moderatorId: staffId(c), playerId, hadPassword })
|
logger.info('staff cleared a password', { moderatorId: staffId(c), playerId, hadPassword })
|
||||||
return c.json({ playerId, hadPassword })
|
return c.json({ playerId, hadPassword })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a player Rec Room Plus by setting `hasPlus` on their account — the same flag the
|
||||||
|
* Discord benefits claim and `runx admin grant-plus` set, with no Discord link involved.
|
||||||
|
* Developer-only. `hadPlus` says whether they already had it; granting it again succeeds,
|
||||||
|
* changes nothing, and is still logged.
|
||||||
|
*
|
||||||
|
* `auth` stamps the flag into the token's `rn.plus` claim, so the player has Plus from their
|
||||||
|
* NEXT sign-in or token refresh, not the moment this answers.
|
||||||
|
*/
|
||||||
|
export async function grantPlusHandler(c: Context<App>) {
|
||||||
|
const playerId = playerIdParam(c)
|
||||||
|
if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400)
|
||||||
|
|
||||||
|
// Read first: `updateAccount` creates the row when there is none.
|
||||||
|
const account = await getAccount(c.env.DB, playerId)
|
||||||
|
if (account === null) return c.json({ error: 'No such player' }, 404)
|
||||||
|
|
||||||
|
const hadPlus = account.hasPlus === true
|
||||||
|
if (!hadPlus) await updateAccount(c.env.DB, playerId, { hasPlus: true })
|
||||||
|
|
||||||
|
await recordPlayerAudit(c, 'grant_plus', { playerId, hadPlus })
|
||||||
|
logger.info('staff granted plus', { moderatorId: staffId(c), playerId, hadPlus })
|
||||||
|
return c.json({ playerId, hasPlus: true, hadPlus })
|
||||||
|
}
|
||||||
|
|||||||
@@ -859,6 +859,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
|
|||||||
'/api/staff/players/1/gift-xp',
|
'/api/staff/players/1/gift-xp',
|
||||||
'/api/staff/players/1/username-changes',
|
'/api/staff/players/1/username-changes',
|
||||||
'/api/staff/players/1/clear-password',
|
'/api/staff/players/1/clear-password',
|
||||||
|
'/api/staff/players/1/grant-plus',
|
||||||
]
|
]
|
||||||
writes.push(
|
writes.push(
|
||||||
'/api/staff/rooms/1/gift-tokens',
|
'/api/staff/rooms/1/gift-tokens',
|
||||||
@@ -874,8 +875,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
|
|||||||
expect(res.status).toBe(403)
|
expect(res.status).toBe(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The gifts are narrower: a moderator is staff, but not a developer.
|
// The gifts and the Plus grant are narrower: a moderator is staff, but not a developer.
|
||||||
for (const path of writes.filter((p) => p.includes('/gift-'))) {
|
for (const path of writes.filter((p) => p.includes('/gift-') || p.includes('/grant-plus'))) {
|
||||||
expect((await staffPost(path, 8101, { amount: 1 })).status).toBe(403)
|
expect((await staffPost(path, 8101, { amount: 1 })).status).toBe(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1964,6 +1965,38 @@ it('clears a player’s password so they can set a new one in game', async () =>
|
|||||||
expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404)
|
expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('grants a player Plus, developers only', async () => {
|
||||||
|
await updateAccount(env.DB, 8330, { username: 'Plusless' })
|
||||||
|
const hasPlus = async () =>
|
||||||
|
await env.DB.prepare(
|
||||||
|
"SELECT json_extract(data, '$.hasPlus') AS plus, json_extract(data, '$.username') AS username FROM account WHERE account_id = 8330"
|
||||||
|
).first<{ plus: number | null; username: string }>()
|
||||||
|
|
||||||
|
// A moderator is staff but not a developer: refused, and nothing is written.
|
||||||
|
expect((await staffPost('/api/staff/players/8330/grant-plus', 8101, {})).status).toBe(403)
|
||||||
|
expect(await hasPlus()).toEqual({ plus: null, username: 'Plusless' })
|
||||||
|
|
||||||
|
let res = await devPost('/api/staff/players/8330/grant-plus', 8110, {})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ playerId: 8330, hasPlus: true, hadPlus: false })
|
||||||
|
// Only the flag is set: the rest of the account is untouched.
|
||||||
|
expect(await hasPlus()).toEqual({ plus: 1, username: 'Plusless' })
|
||||||
|
|
||||||
|
// Granting it again is harmless, and says they already had it.
|
||||||
|
res = await devPost('/api/staff/players/8330/grant-plus', 8110, {})
|
||||||
|
expect(await res.json()).toEqual({ playerId: 8330, hasPlus: true, hadPlus: true })
|
||||||
|
|
||||||
|
expect(await auditRows('grant_plus', 8330)).toEqual([
|
||||||
|
{ actor: 8110, data: { playerId: 8330, hadPlus: false } },
|
||||||
|
{ actor: 8110, data: { playerId: 8330, hadPlus: true } },
|
||||||
|
])
|
||||||
|
// An unknown player is a 404, and no account is conjured for them.
|
||||||
|
expect((await devPost('/api/staff/players/8396/grant-plus', 8110, {})).status).toBe(404)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT 1 AS hit FROM account WHERE account_id = 8396').first()
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
/** A custom avatar item made by `creatorAccountId`; published unless `accessibility` says. */
|
/** A custom avatar item made by `creatorAccountId`; published unless `accessibility` says. */
|
||||||
const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1) =>
|
const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1) =>
|
||||||
createCustomAvatarItem(env.DB, {
|
createCustomAvatarItem(env.DB, {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
giftRoomTokensHandler,
|
giftRoomTokensHandler,
|
||||||
giftTokensHandler,
|
giftTokensHandler,
|
||||||
giftXpHandler,
|
giftXpHandler,
|
||||||
|
grantPlusHandler,
|
||||||
linkedAccountsHandler,
|
linkedAccountsHandler,
|
||||||
playerHistoryHandler,
|
playerHistoryHandler,
|
||||||
requireDeveloper,
|
requireDeveloper,
|
||||||
@@ -560,6 +561,8 @@ const app = new Hono<App>()
|
|||||||
// Its sibling: the same gift to every account whose Discord link holds a role, offline
|
// Its sibling: the same gift to every account whose Discord link holds a role, offline
|
||||||
// included — the supporter cron's audience, with an operator's amount and message.
|
// included — the supporter cron's audience, with an operator's amount and message.
|
||||||
.post('/api/staff/discord-roles/:roleId/gift-tokens', requireDeveloper, giftRoleTokensHandler)
|
.post('/api/staff/discord-roles/:roleId/gift-tokens', requireDeveloper, giftRoleTokensHandler)
|
||||||
|
// Rec Room Plus is worth tokens and a discount, so granting it is developer-only too.
|
||||||
|
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
|
||||||
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
||||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user