Skip to content

Commit 47330f9

Browse files
MajorTalclaude
andcommitted
feat(astro): AccountSecurity component (auth-hosted-surface-parity 4.2)
Renders the four account-security sections (password/passkeys/sessions/identities) as plain-HTML forms POSTing to the hosted /auth/account/* routes, with the gateway-verified double-submit CSRF token and a sign-out-everywhere action. Reads auth.account.getSecurity() for the ownership-qualified state. WebAuthn passkey-add and OAuth identity-link delegate to the hosted ceremony pages (no-client-JS component model, matching SignIn/UserButton). Exported from components/index.ts and copied into dist by the build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 81a0c54 commit 47330f9

3 files changed

Lines changed: 231 additions & 1 deletion

File tree

astro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
"README.md"
6363
],
6464
"scripts": {
65-
"build": "tsc && cp src/Image.astro dist/Image.astro && mkdir -p dist/components && cp src/components/Run402Picture.astro dist/components/Run402Picture.astro && cp src/components/Run402Image.astro dist/components/Run402Image.astro && cp src/components/SignIn.astro dist/components/SignIn.astro && cp src/components/SignUp.astro dist/components/SignUp.astro && cp src/components/UserButton.astro dist/components/UserButton.astro && cp src/components/SignedIn.astro dist/components/SignedIn.astro && cp src/components/SignedOut.astro dist/components/SignedOut.astro",
65+
"build": "tsc && cp src/Image.astro dist/Image.astro && mkdir -p dist/components && cp src/components/Run402Picture.astro dist/components/Run402Picture.astro && cp src/components/Run402Image.astro dist/components/Run402Image.astro && cp src/components/SignIn.astro dist/components/SignIn.astro && cp src/components/SignUp.astro dist/components/SignUp.astro && cp src/components/UserButton.astro dist/components/UserButton.astro && cp src/components/SignedIn.astro dist/components/SignedIn.astro && cp src/components/SignedOut.astro dist/components/SignedOut.astro && cp src/components/AccountSecurity.astro dist/components/AccountSecurity.astro",
6666
"test": "node --experimental-test-module-mocks --test --import tsx src/resolver.test.ts src/cache.test.ts src/scanner.test.ts src/uploader.test.ts src/component.test.ts src/manifest.test.ts src/blurhash-decoder.test.ts src/build-manifest.test.ts src/ssr-adapter.test.ts src/release-slice.test.ts src/components/Run402Image/types.test.ts src/components/Run402Image/core.test.ts src/components/Run402Image/react.test.tsx src/components/Run402Image/byte-identity.test.tsx src/components/Run402Image/degradation-manifest.test.ts src/components/Run402Image/wrong-entry-point.test.tsx src/components/Run402Image/avif-deferral.test.ts src/components/Run402Image/jsx-smoke.test.tsx"
6767
},
6868
"engines": {
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
---
2+
/**
3+
* `<AccountSecurity />` — the component-first account-security panel
4+
* (auth-hosted-surface-parity §4.2). Renders, per requested section, plain
5+
* HTML forms that POST to the platform-hosted `/auth/account/*` routes. No
6+
* client JavaScript for the password / session-revoke / passkey-remove /
7+
* identity-unlink flows — CSRF is the gateway-verified double-submit token
8+
* (the same mechanism `<UserButton />` uses). The gateway enforces freshness,
9+
* session rotation, and host-gating server-side.
10+
*
11+
* Sections (rendered in the order requested via `sections`):
12+
* - "password" — set or change the Run402 password. Freshness-gated
13+
* server-side (recent re-auth required); the route
14+
* rotates other sessions on success.
15+
* - "passkeys" — show the registered-passkey count + remove one. ADD
16+
* links to the hosted passkey-register ceremony (the
17+
* browser WebAuthn `navigator.credentials` call lives on
18+
* the hosted page, not in this server-rendered form).
19+
* - "sessions" — revoke a single browser session or sign out everywhere.
20+
* - "identities" — list linked OAuth identities + unlink. LINK uses the
21+
* hosted OAuth start route (the §4.5 startLink redirect).
22+
*
23+
* Reads `auth.account.getSecurity()` for the ownership-qualified state
24+
* (`has_run402_password`, `run402_passkey_count`, `run402_identities`, …)
25+
* and `auth.csrfToken()` for the double-submit token. Anonymous visitors
26+
* render nothing — gate the parent surface on `<SignedIn>`.
27+
*
28+
* Capability `auth-hosted-surface-parity` / spec `hosted-auth-ui`.
29+
*
30+
* Usage:
31+
* ```astro
32+
* ---
33+
* import { AccountSecurity } from "@run402/astro/components";
34+
* ---
35+
* <AccountSecurity sections={["password", "sessions"]} />
36+
* ```
37+
*/
38+
39+
// @ts-ignore — `@run402/functions`' `auth` namespace is resolved at
40+
// build/runtime in the consumer's Astro project (the Vite plugin handles
41+
// the dependency); the package itself does not type-check `.astro` files.
42+
import { auth } from "@run402/functions";
43+
44+
type Section = "password" | "passkeys" | "sessions" | "identities";
45+
46+
interface Props {
47+
/** Which panels to render, in this order. Default: all four. */
48+
sections?: Section[];
49+
/** Extra class on the wrapper. */
50+
class?: string;
51+
}
52+
53+
const {
54+
sections = ["password", "passkeys", "sessions", "identities"],
55+
class: className = "",
56+
} = Astro.props;
57+
58+
// getSecurity() returns the rich ownership-qualified read (or null when
59+
// anonymous) and triggers the cache-bypass taint, so the rendered HTML is
60+
// correctly marked non-cacheable.
61+
const security = await auth.account.getSecurity();
62+
63+
let csrfToken: string | null = null;
64+
try {
65+
csrfToken = security ? auth.csrfToken() : null;
66+
} catch {
67+
// Session vanished between getSecurity() and the token derivation — render
68+
// nothing; the next request sees anonymous state.
69+
csrfToken = null;
70+
}
71+
72+
const show = (s: Section): boolean =>
73+
security !== null && csrfToken !== null && sections.includes(s);
74+
---
75+
76+
{security && csrfToken && (
77+
<div class={`r402-account-security ${className}`.trim()} data-r402-account-security>
78+
{show("password") && (
79+
<section class="r402-as-section" data-section="password">
80+
<h3>{security.has_run402_password ? "Change password" : "Set a password"}</h3>
81+
<form method="POST" action="/auth/account/password" class="r402-as-form">
82+
<input type="hidden" name="_csrf" value={csrfToken} />
83+
{security.has_run402_password && (
84+
<label>
85+
Current password
86+
<input type="password" name="current_password" autocomplete="current-password" required />
87+
</label>
88+
)}
89+
<label>
90+
New password
91+
<input type="password" name="new_password" autocomplete="new-password" required minlength={8} />
92+
</label>
93+
<button type="submit">
94+
{security.has_run402_password ? "Update password" : "Set password"}
95+
</button>
96+
</form>
97+
<p class="r402-as-hint">Recent re-authentication may be required.</p>
98+
</section>
99+
)}
100+
101+
{show("passkeys") && (
102+
<section class="r402-as-section" data-section="passkeys">
103+
<h3>Passkeys</h3>
104+
<p>
105+
{security.run402_passkey_count} passkey{security.run402_passkey_count === 1 ? "" : "s"} registered
106+
{security.has_run402_passkey_for_current_rp ? " (including one for this site)" : ""}.
107+
</p>
108+
<a class="r402-as-link" href="/auth/passkeys/register">Add a passkey</a>
109+
{security.run402_passkey_count > 0 && (
110+
<form method="POST" action="/auth/account/passkeys/remove" class="r402-as-form">
111+
<input type="hidden" name="_csrf" value={csrfToken} />
112+
<label>
113+
Passkey credential id
114+
<input type="text" name="credential_id" required />
115+
</label>
116+
<button type="submit">Remove passkey</button>
117+
</form>
118+
)}
119+
</section>
120+
)}
121+
122+
{show("sessions") && (
123+
<section class="r402-as-section" data-section="sessions">
124+
<h3>Active sessions</h3>
125+
<form method="POST" action="/auth/account/sessions/revoke" class="r402-as-form">
126+
<input type="hidden" name="_csrf" value={csrfToken} />
127+
<label>
128+
Session id
129+
<input type="text" name="session_id" required />
130+
</label>
131+
<button type="submit">Revoke session</button>
132+
</form>
133+
<form method="POST" action="/auth/account/sign-out-everywhere" class="r402-as-form">
134+
<input type="hidden" name="_csrf" value={csrfToken} />
135+
<button type="submit" class="r402-as-danger">Sign out everywhere</button>
136+
</form>
137+
</section>
138+
)}
139+
140+
{show("identities") && (
141+
<section class="r402-as-section" data-section="identities">
142+
<h3>Connected accounts</h3>
143+
{security.run402_identities.length === 0 ? (
144+
<p>No connected accounts.</p>
145+
) : (
146+
<ul class="r402-as-identities">
147+
{security.run402_identities.map((id: { provider: string; provider_sub: string; provider_email: string | null }) => (
148+
<li>
149+
<span>{id.provider}{id.provider_email ? ` — ${id.provider_email}` : ""}</span>
150+
<form method="POST" action="/auth/account/identities/unlink" class="r402-as-inline">
151+
<input type="hidden" name="_csrf" value={csrfToken} />
152+
<input type="hidden" name="provider" value={id.provider} />
153+
<input type="hidden" name="provider_sub" value={id.provider_sub} />
154+
<button type="submit">Unlink</button>
155+
</form>
156+
</li>
157+
))}
158+
</ul>
159+
)}
160+
<a class="r402-as-link" href="/auth/sign-in/oauth/google/start?intent=link">Connect Google</a>
161+
</section>
162+
)}
163+
</div>
164+
)}
165+
166+
<style>
167+
.r402-account-security {
168+
display: flex;
169+
flex-direction: column;
170+
gap: 1.5rem;
171+
}
172+
.r402-as-section {
173+
display: flex;
174+
flex-direction: column;
175+
gap: 0.5rem;
176+
}
177+
.r402-as-section h3 {
178+
margin: 0;
179+
font-size: 1rem;
180+
}
181+
.r402-as-form {
182+
display: flex;
183+
flex-direction: column;
184+
gap: 0.5rem;
185+
max-width: 24rem;
186+
margin: 0;
187+
}
188+
.r402-as-form label {
189+
display: flex;
190+
flex-direction: column;
191+
gap: 0.25rem;
192+
font-size: 0.875rem;
193+
}
194+
.r402-as-inline {
195+
display: inline;
196+
margin: 0;
197+
}
198+
.r402-as-identities {
199+
list-style: none;
200+
padding: 0;
201+
margin: 0;
202+
display: flex;
203+
flex-direction: column;
204+
gap: 0.5rem;
205+
}
206+
.r402-as-identities li {
207+
display: flex;
208+
align-items: center;
209+
justify-content: space-between;
210+
gap: 0.75rem;
211+
}
212+
.r402-as-hint {
213+
margin: 0;
214+
font-size: 0.75rem;
215+
color: var(--r402-as-hint-fg, #777);
216+
}
217+
.r402-as-danger {
218+
color: var(--r402-as-danger-fg, #b00020);
219+
}
220+
.r402-as-link {
221+
font-size: 0.875rem;
222+
}
223+
</style>

astro/src/components/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ export { default as SignedIn } from "./SignedIn.astro";
4646
// @ts-expect-error — .astro file, see Run402Picture comment.
4747
export { default as SignedOut } from "./SignedOut.astro";
4848

49+
// auth-hosted-surface-parity §4.2 — the component-first account-security
50+
// panel. Renders plain-HTML forms POSTing to the hosted /auth/account/*
51+
// routes (password / passkeys / sessions / identities), CSRF via the
52+
// gateway-verified double-submit token. Reads auth.account.getSecurity().
53+
// @ts-expect-error — .astro file, see Run402Picture comment.
54+
export { default as AccountSecurity } from "./AccountSecurity.astro";
55+
4956
// Re-export the prop interface + AssetRef shape + error class so
5057
// consumers can compose project-specific wrappers (e.g., a `<HeroImage>`
5158
// that wraps `<Run402Image>` with project-default `sizes`) without

0 commit comments

Comments
 (0)