feat(auth): sign in with Steam (MODULE_API 1.17.0)

Steam is a built-in provider beside Google and Discord (STEAM_SIGNIN.md,
D392-D396). OpenID 2.0: the assertion is verified server to server with
check_authentication, return_to must match byte for byte, and the claimed id
must be a 17-digit SteamID64. A Steam Web API key is required to enable it
(D394); persona name and avatar are stored on the identity and refreshed at
each sign-in, and a failed lookup never blocks one. Steam never signs anybody
up (D395), on the web or the mobile bridge. Several Steam accounts may be
linked; DELETE /auth/me/account/identities/:provider takes ?id= to unlink one.

MODULE_API 1.17.0: ctx.identities.list, api.onIdentityLinking (refuse-only,
fails closed, module text never shown) and api.onIdentityLinked. No unlink
hook (D393). Schema additive; OpenAPI, routes and engagement manifests
regenerated with no modules installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-19 14:02:49 -05:00
parent 6ba2ed90ed
commit 5e398d8acd
30 changed files with 1346 additions and 126 deletions

View File

@@ -0,0 +1,66 @@
import ProviderIcon from '../ProviderIcon.jsx'
// What the player's and the staff account pages share about a linked sign-in
// identity. Steam is the one provider that differs (STEAM_SIGNIN.md): a person may
// link several Steam accounts, each shows its persona name, avatar and SteamID64,
// and unlinking one removes only the sign-in — a game link made from it stays
// (D393).
// Providers a person may link more than one account of.
const MULTI_LINK = new Set(['steam'])
export function canLinkAgain(providerId) {
return MULTI_LINK.has(providerId)
}
export function identityIcon(provider) {
return provider === 'google' || provider === 'discord' || provider === 'steam' ? provider : 'oidc'
}
// The outcome of a link round trip, from the query string it returns with.
export function linkBanner(search) {
const q = new URLSearchParams(search)
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user on this site.' }
if (q.get('link_error') === 'refused') return { ok: false, text: 'That account cannot be linked right now. Please try again later.' }
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
return null
}
// Providers to offer a Link button for: every enabled one not yet linked, and a
// multi-link one (Steam) always.
export function linkableProviders(available, linked) {
const linkedIds = new Set(linked.map((i) => i.provider))
return available.filter((p) => !linkedIds.has(p.id) || canLinkAgain(p.id))
}
export function unlinkPrompt(identity, providerName) {
if (identity.provider === 'steam') {
const who = identity.display_name ? `${identity.display_name} (${identity.subject})` : identity.subject
return `Unlink the Steam account ${who}? You will no longer be able to sign in with it. A game link made with it stays until you remove it on that game's page.`
}
return `Unlink ${providerName} from your account?`
}
// The icon (or Steam avatar), the provider name, and what identifies the account.
export function IdentitySummary({ identity, providerName }) {
const isSteam = identity.provider === 'steam'
return (
<>
<span style={{ display: 'inline-flex', width: 20, height: 20, color: 'var(--head)', flex: '0 0 auto' }}>
{isSteam && identity.avatar_url ? (
<img src={identity.avatar_url} alt="" width={20} height={20} style={{ borderRadius: 4 }} referrerPolicy="no-referrer" />
) : (
<ProviderIcon icon={identityIcon(identity.provider)} size={20} />
)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem', overflowWrap: 'anywhere' }}>
{isSteam && identity.display_name ? `${providerName}: ${identity.display_name}` : providerName}
</div>
{isSteam && identity.subject && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.subject}</div>}
{!isSteam && identity.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{identity.email}</div>}
</div>
</>
)
}