mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[studio] beta studio (#114)
* rrs stuff * studio service yay * rrs whitelist * moree studiooooooo fellowwww * rec room studio room loading yay * fixes yay * fix 2 because idk yayuh * Update cdn.app.ts * ccontent length fix? * Preserve content-length and upgrade metadata Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable. * fix x524234 * shirt thing? * [auth] remove grant table as we already have audit_log for now * rename table to generic device_grant * cleanup/lint --------- Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
This commit is contained in:
@@ -27,9 +27,11 @@ import {
|
||||
usernamesFor,
|
||||
where,
|
||||
} from './api'
|
||||
import { DeviceLinkPage } from './DeviceLink'
|
||||
import { customAvatarItemIdFromPath, ItemPage } from './Item'
|
||||
import { ModerationPage } from './Moderation'
|
||||
import { StatsPage } from './Stats'
|
||||
import { StudioAccessPage } from './StudioAccess'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Hosts } from './api'
|
||||
@@ -1598,6 +1600,10 @@ export function App() {
|
||||
navigate={navigate}
|
||||
onAuthed={setAccount}
|
||||
/>
|
||||
) : path === '/device' ? (
|
||||
// Studio opens this from verification_uri_complete. The code was minted by
|
||||
// auth; this page only approves it with the website session.
|
||||
<DeviceLinkPage account={account} search={search} navigate={navigate} />
|
||||
) : path === '/account' ? (
|
||||
<AccountPage account={account} config={config} navigate={navigate} onChange={setAccount} />
|
||||
) : path === '/claim' ? (
|
||||
@@ -1615,6 +1621,12 @@ export function App() {
|
||||
// on any of them falls through to the SPA shell; the page then gates itself on
|
||||
// the token's role, and every endpoint behind it re-checks.
|
||||
<ModerationPage account={account} path={path} search={search} navigate={navigate} />
|
||||
) : path === '/settings/recroomstudio' || path === '/settings/recroomstudio/' ? (
|
||||
// The URL Studio already opens from the "not authorized to upload" dialog.
|
||||
// A client-side route like /moderation: not in run_worker_first, so a cold
|
||||
// load falls through to the SPA shell. The page shows one account's own
|
||||
// status to whoever is signed in, and the whitelist editor only to staff.
|
||||
<StudioAccessPage account={account} navigate={navigate} />
|
||||
) : path === '/stats' ? (
|
||||
// Unlinked on purpose — nothing in the nav or footer points here; it's for whoever
|
||||
// is handed the URL. Public all the same, and a client-side route like the rest:
|
||||
@@ -1706,6 +1718,19 @@ function NavBar({
|
||||
Moderation
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin() && (
|
||||
<Link
|
||||
to="/settings/recroomstudio"
|
||||
navigate={navigate}
|
||||
className={
|
||||
path === '/settings/recroomstudio' || path === '/settings/recroomstudio/'
|
||||
? 'active'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
Studio access
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
|
||||
My account
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { call, useAction, where } from './api'
|
||||
|
||||
/**
|
||||
* The page RecFlare Studio opens after `POST /connect/deviceauthorization`.
|
||||
*
|
||||
* Studio shows `verification_uri` (`/device`) and opens `verification_uri_complete`
|
||||
* (`/device?user_code=…`) in the browser. The API that created the code stays on
|
||||
* `auth`; this page only approves or denies it, with the website session's token.
|
||||
*/
|
||||
export function DeviceLinkPage({
|
||||
account,
|
||||
search,
|
||||
navigate,
|
||||
}: {
|
||||
/** undefined while the session is still being checked, null when signed out. */
|
||||
account: { username: string } | null | undefined
|
||||
search: string
|
||||
navigate: (to: string) => void
|
||||
}) {
|
||||
const fromQuery = new URLSearchParams(search).get('user_code') ?? ''
|
||||
const [code, setCode] = useState(fromQuery)
|
||||
const { pending, error, done, run } = useAction()
|
||||
|
||||
useEffect(() => {
|
||||
if (account !== null) return
|
||||
const next = `/device${search}`
|
||||
navigate(`/login?next=${encodeURIComponent(next)}`)
|
||||
}, [account, navigate, search])
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting to sign in…'}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const submit = (path: 'approve' | 'deny') => {
|
||||
void run(async () => {
|
||||
await call(`${where().auth}/connect/device/${path}`, {
|
||||
authed: true,
|
||||
form: { user_code: code.trim() },
|
||||
})
|
||||
return path === 'approve'
|
||||
? 'Studio is signed in. You can close this tab and go back to the editor.'
|
||||
: 'Studio was not signed in. You can close this tab.'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="card">
|
||||
<h2>Sign in to RecFlare Studio</h2>
|
||||
<p className="muted">
|
||||
The editor asked to use <strong>{account.username}</strong>. The code below is the one
|
||||
Studio is showing. Allowing it signs the editor into this account.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit('approve')
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Code
|
||||
<input
|
||||
value={code}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pending || done !== ''}>
|
||||
Allow
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending || done !== '' || code.trim() === ''}
|
||||
onClick={() => submit('deny')}
|
||||
>
|
||||
Don’t allow
|
||||
</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { call, isAdmin, useAction } from './api'
|
||||
|
||||
/**
|
||||
* The page RecFlare Studio opens when an account cannot upload.
|
||||
*
|
||||
* The editor's dialog links to `/settings/recroomstudio`. Anyone can land here
|
||||
* and, once signed in, see whether this account is on the upload list. Staff
|
||||
* also get the list itself: adding a player puts `betastudio` on their next
|
||||
* Studio sign-in or token refresh. A session already open in the editor keeps
|
||||
* its old access until then.
|
||||
*/
|
||||
|
||||
const PAGE = '/settings/recroomstudio'
|
||||
|
||||
interface AccessRow {
|
||||
accountId: number
|
||||
username: string | null
|
||||
displayName: string | null
|
||||
}
|
||||
|
||||
export function StudioAccessPage({
|
||||
account,
|
||||
navigate,
|
||||
}: {
|
||||
/** undefined while the session is still being checked, null when signed out. */
|
||||
account: { accountId: number; username: string } | null | undefined
|
||||
navigate: (to: string) => void
|
||||
}) {
|
||||
const staff = account !== undefined && account !== null && isAdmin()
|
||||
const [granted, setGranted] = useState(false)
|
||||
const [rows, setRows] = useState<AccessRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [removing, setRemoving] = useState<number | null>(null)
|
||||
const [removeError, setRemoveError] = useState('')
|
||||
const add = useAction()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const me = await call<{ granted: boolean }>('/api/studio-access', { authed: true })
|
||||
setGranted(me.granted === true)
|
||||
if (isAdmin()) {
|
||||
const page = await call<{ accounts: AccessRow[] }>('/api/staff/studio-access', {
|
||||
authed: true,
|
||||
})
|
||||
setRows(page.accounts ?? [])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const accountId = account?.accountId
|
||||
useEffect(() => {
|
||||
if (accountId === undefined) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
void load()
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accountId, load])
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
await load()
|
||||
setLoadError('')
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const submitAdd = () => {
|
||||
void add.run(async () => {
|
||||
const result = await call<{ username: string; alreadyGranted: boolean }>(
|
||||
'/api/staff/studio-access',
|
||||
{ method: 'POST', authed: true, json: { username: username.trim() } }
|
||||
)
|
||||
setUsername('')
|
||||
await reload()
|
||||
return result.alreadyGranted
|
||||
? `@${result.username} is already on the list.`
|
||||
: `@${result.username} can upload the next time they sign in to Studio.`
|
||||
})
|
||||
}
|
||||
|
||||
const remove = (row: AccessRow) => {
|
||||
setRemoving(row.accountId)
|
||||
setRemoveError('')
|
||||
void (async () => {
|
||||
try {
|
||||
await call(`/api/staff/studio-access/${row.accountId}`, { method: 'DELETE', authed: true })
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setRemoveError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setRemoving(null)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
|
||||
|
||||
return (
|
||||
<main className={staff ? 'shell wide' : 'shell'}>
|
||||
<h1>RecFlare Studio access</h1>
|
||||
<section className="card">
|
||||
<p className="muted">
|
||||
RecFlare Studio signs in with any account. Uploading is limited to accounts on this list.
|
||||
Access is picked up the next time you sign in to Studio, or when that session refreshes. A
|
||||
session you already have keeps its old access until then, and a session lasts a day.
|
||||
</p>
|
||||
{account === undefined ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : account === null ? (
|
||||
<p>
|
||||
<a
|
||||
className="cta"
|
||||
href={signInHref}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(signInHref)
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</a>{' '}
|
||||
<span className="muted">to see whether this account can upload.</span>
|
||||
</p>
|
||||
) : loading ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : loadError ? (
|
||||
<p className="error">{loadError}</p>
|
||||
) : granted ? (
|
||||
<p>
|
||||
<strong>{account.username}</strong> can upload in RecFlare Studio. If the editor still
|
||||
says otherwise, sign out of it and sign in again.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>{account.username}</strong> cannot upload in RecFlare Studio yet. A moderator
|
||||
can add this account to the list.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{staff && account && (
|
||||
<section className="card">
|
||||
<h2>Whitelist</h2>
|
||||
<p className="muted">
|
||||
Adding a player puts the betastudio role on their next Studio sign-in. Developers are
|
||||
not on this list unless someone adds them.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submitAdd()
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
value={username}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={add.pending || username.trim() === ''}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
{add.error && <p className="error">{add.error}</p>}
|
||||
{add.done && <p className="ok">{add.done}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : loadError ? null : rows.length === 0 ? (
|
||||
<p className="muted">Nobody is on the list.</p>
|
||||
) : (
|
||||
<ul className="studio-access-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.accountId} className="studio-access-row">
|
||||
<span>
|
||||
<strong>
|
||||
{row.username ? `@${row.username}` : `Account ${row.accountId}`}
|
||||
</strong>
|
||||
{row.displayName && row.displayName !== row.username ? (
|
||||
<span className="muted"> · {row.displayName}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={removing === row.accountId}
|
||||
onClick={() => remove(row)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{removeError && <p className="error">{removeError}</p>}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1226,6 +1226,26 @@ button[type='submit']:disabled {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* The studio whitelist: a name on the left, Remove on the right. */
|
||||
.studio-access-list {
|
||||
list-style: none;
|
||||
margin: 16px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.studio-access-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.studio-access-row button {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ---- Stats ---------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user