feat(dashboard): platform accounts — sign-in modal (SIWE/email/Google) + drafts on the User API#2045
feat(dashboard): platform accounts — sign-in modal (SIWE/email/Google) + drafts on the User API#2045brunod-e wants to merge 10 commits into
Conversation
…er API Frontend for the user-accounts architecture (stacked on the @anticapture/ user-api service). Depends on the User API being deployed and reachable via USER_API_URL. - /api/user proxy: same-origin, cookie-relaying reverse proxy to the User API (forwards x-forwarded-host + Origin for better-auth behind a path-rewriting proxy). - better-auth react client (siweClient) over the /api/user path. - Sign-in modal from the design, mounted app-wide via LoginProvider; the design-system Modal gains an optional header for bespoke layouts. Full SIWE ceremony via useSiweLogin (opens RainbowKit connect, then signs). Whitelabel is wallet-only; Email/Google shown per design, disabled until the Phase 5 server plugins land. - Drafts moved off the gateful draft SDK onto a session-scoped /api/user drafts client: no address params (identity from the cookie), server- derived isOwner replaces author-vs-wallet comparison, Save prompts sign-in when unauthenticated. Removes dead useDraftRecipient. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The modal gains email and Google methods alongside wallet SIWE: - Email: a two-step flow (enter email -> Check your inbox) sending a magic link via the User API, with a 30s resend cooldown. - Google: better-auth social sign-in, returning to the current page. - Each method renders only where enabled via NEXT_PUBLIC_EMAIL_LOGIN / NEXT_PUBLIC_GOOGLE_LOGIN; whitelabel stays wallet-only. - authClient gains the magicLink client plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 24ede5e5e6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| // Each method is enabled only where its server plugin is configured; a disabled | ||
| // method is hidden rather than shown broken. Whitelabel is wallet-only. | ||
| const emailLoginEnabled = process.env.NEXT_PUBLIC_EMAIL_LOGIN !== "false"; |
There was a problem hiding this comment.
Keep email login hidden unless explicitly enabled
Because this defaults to enabled, any environment that does not set NEXT_PUBLIC_EMAIL_LOGIN renders the Email button even when the User API has no magic-link sender configured; the User API only registers that route when RESEND_API_KEY creates a magicLink plugin, so clicking Email in a SIWE-only local/new deployment sends users to a 404. Make this opt-in like Google, or otherwise derive it from the server capability, so disabled methods stay hidden as the comment says.
Useful? React with 👍 / 👎.
| hydratedDraftIdRef.current = draftId; | ||
| if (!shared) return; | ||
| setSharedAuthor(shared.author); | ||
| setSharedAuthor(shared.authorAddress ?? undefined); |
There was a problem hiding this comment.
Preserve walletless draft authors in previews
When a draft is authored via email/Google, the User API returns authorAddress: null; converting that to undefined makes the later sharedAuthor ?? address ?? "" fallback use the viewer's connected wallet. A recipient opening such a shared draft is then displayed as the author/proposer, while an owner without a wallet passes an empty address into the preview. Keep the null walletless-author state distinct and render a non-wallet author instead of falling back to the viewer.
Useful? React with 👍 / 👎.
|
|
||
| export const useDrafts = (daoId: string): UseDraftsReturn => { | ||
| const { address } = useAccount(); | ||
| const { data: session, isPending: isSessionPending } = useSession(); |
There was a problem hiding this comment.
Stop hiding session drafts behind wallet connection
Switching this hook to Better Auth sessions means drafts can belong to email/Google users who have no connected wagmi wallet, but the drafts list consumer still renders drafts only under activeTab === "drafts" && isConnected in GovernanceSection.tsx. After one of these users saves a draft and is redirected to ?tab=drafts, they see the normal proposals list instead of their saved drafts. Gate that view on the auth session/needsAuth state rather than wallet connection.
Useful? React with 👍 / 👎.
🎨 UI Review
Sign-in modal (
|
isadorable-png
left a comment
There was a problem hiding this comment.
🎨 UI Review
Review basis (Path B — Figma link found, MCP unreachable): The PR description links Figma node 3315-19892 (sign-in modal, states ③.1/③.2), but the Figma MCP connector needs an OAuth authorization this non-interactive session can't complete. Findings below are grounded in the diff, the design-system source (Modal, Button, IconButton, DrawerHeader), and UX heuristics — not a pixel-level Figma diff. Please cross-check against the Figma frames directly, especially #3 and #4 below.
Preview: Only the anticapture-storybook Vercel project deploys for this branch (the apps/dashboard project is "Ignored" per this repo's root-directory scoping quirk). The reachable storybook preview URL returned 403 Forbidden (SAML-gated), so no live app preview could be checked either.
Sign-in modal — apps/dashboard/shared/components/auth/LoginModal.tsx
Must-fix
-
Save → sign-in flow silently drops the user's action. [Code-only]
ProposalCreationForm.handleSaveDraftcallsopenLogin()whendrafts.needsAuth(apps/dashboard/features/create-proposal/components/ProposalCreationForm.tsx~L311-317), butLoginProvideris mounted once globally with onlyisWhitelabel(apps/dashboard/shared/providers/GlobalProviders.tsxL43) anduseLogin()'sopenLogin()takes no resume callback. On successful sign-in,LoginModal.tsxL45-48 just doesonOpenChange(false); onAuthenticated?.()— but nothing is ever passed asonAuthenticatedat the global mount, so the modal closes and the save the user asked for never happens, with zero feedback. The user has to notice nothing happened and click Save again. Wire a resume callback throughopenLogin(onSuccess), or at minimum toast "Signed in — click Save again" after auth. -
Headerless close button loses the 44px touch target. [Code-only]
ModalHeader's close button explicitly widens its hit area to 44px (className="-m-2 size-11",apps/dashboard/shared/components/design-system/modal/modal-header/ModalHeader.tsxL52). The new headerless branch added inModal.tsx(~L146-152) renders the sameIconButtonatsize="sm"with no size override (className="absolute right-3 top-3 z-10"), so it renders at its default ~22px box — this is the only way to close the sign-in modal on desktop, and it's half the sibling header's target and below the 44px minimum. Add the equivalent size override. -
Subtitle copy doesn't match which methods are actually shown. [Code-only] The default view always renders "Use your wallet, or just your email." (
LoginModal.tsxL145), independent ofshowEmail/showGoogle(L51-53). If a deployment enables Google but disables email (NEXT_PUBLIC_EMAIL_LOGIN=false,NEXT_PUBLIC_GOOGLE_LOGIN=true), the modal renders a Google button while the copy still promises "just your email." Make the subtitle conditional on which methods are enabled.
Nice-to-have
-
Mobile bottom sheet still shows a header bar in headerless mode; desktop doesn't. [UX-heuristic] On mobile,
Modalroutes throughDrawerHeader, which always renders itsbg-surface-contrastbar (apps/dashboard/shared/components/design-system/drawer/drawer-header/DrawerHeader.tsxL22) — headerless mode only swaps the title for ansr-onlyspan, so the bar (and its own close button) stays visible above the login content. Desktop's headerless branch removes the chrome entirely and floats a bare icon over the body instead. That's an inconsistent "headerless" treatment across breakpoints — worth confirming against the Figma mobile frame once access is restored. -
Screen-reader accessible name doesn't track the visible step. [UX-heuristic] The sr-only
DialogPrimitive.Titleis pinned to the staticariaLabel="Sign in to Anticapture"(LoginModal.tsxL80) across all three views. A screen-reader user always hears "Sign in to Anticapture," even on the "Check your inbox" step, while sighted users see the step-specific heading. Consider passing the current view's heading as the accessible name.
Validated — no change needed
- Keyboard dismiss (Esc) and focus trap: unaffected by the headerless change — both come from the underlying Radix
Dialog.Root/Dialog.Content, independent of whether a header renders. - Accessible name for the headerless dialog: correctly supplied via a
DialogPrimitive.Title(sr-only), so Radix'saria-labelledbyrequirement is satisfied — no missing-label a11y warning. - Touch targets inside the modal body: Connect wallet / Email / Google / Resend all use
Button size="lg"(py-3, ≥44px) — fine, only the standalone closeIconButton(#2) is undersized.
Drafts flow — ProposalCreationForm.tsx
Reviewed only for the visible consequence of the session-gating change (must-fix #1 above); the server-derived isOwner logic itself is out of scope for a UI pass.
Caveat recap: Figma MCP could not be authorized in this session — please have a human confirm #3 (copy per config) and #4 (mobile chrome) against the linked frames (node 3315-19892) before merge. No live app preview was reachable either.
Findings: 5 total — 3 must-fix, 2 nice-to-have.
Generated by Claude Code
scripts/dev.sh gains an optional User API lane (port 4003) mirroring the Address Enrichment one — started via `railway run` when the service is available (it needs the Railway dev Postgres), exporting USER_API_URL for the dashboard /api/user proxy, and skipped otherwise. Adds the `pnpm user-api` root alias and documents the port in the local-dev-stack skill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
When a signed-in email/Google user has no connected wallet and uses Preview → Copy link on a new or dirty draft, this wallet check returns before drafts.saveDraft, even though saving is now session-scoped and supports walletless authors. Gate this path the same way as Save Draft (drafts.needsAuth/openLogin) so authenticated walletless users can save and share drafts without being told to connect a wallet.
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Drafts are session-scoped: load them once authenticated. Identity comes | ||
| // from the session cookie, never a request param. Legacy drafts created | ||
| // before accounts are migrated server-side (DAO DB -> User DB, claimed on | ||
| // first SIWE login), so there is nothing to migrate from the browser here. |
There was a problem hiding this comment.
Preserve local drafts during migration
Because this hook now only loads User API rows, users who still have pre-API browser drafts under the old draftKey(daoId, address) localStorage key lose access to them after signing in; the parent hook explicitly read those drafts and posted missing ones on first wallet connect. Keep a one-time browser-local migration/import path here before relying solely on server-side rows, otherwise those local-only drafts never appear or get saved to the User API.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 763895281a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // Saving is session-gated now, not wallet-gated: prompt sign-in when there | ||
| // is no session (the user may be connected but not authenticated). | ||
| if (drafts.needsAuth) { | ||
| openLogin(); |
There was a problem hiding this comment.
Share drafts through the session gate
After this change, saving drafts is session-gated so email/Google users can create User API drafts without a connected wallet, but handleShare still checks !address before it calls the same drafts.saveDraft path for new or dirty drafts. In an email/Google session with no wallet, Copy Link/Share always stops with “Connect a wallet…” even though the server can persist the draft from the session; make the share path use the same drafts.needsAuth/login gate instead of wallet presence.
Useful? React with 👍 / 👎.
Railway's edge proxy in front of the User API overwrites the x-forwarded-* set, so the per-host SIWE resolution never saw the real dashboard host and rejected every request as untrusted_host. Send the host in a custom x-anticapture-host header (passed through untouched by the edge); keep x-forwarded-host for edge-less local dev. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 56bf29d262
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| daoId as GetDraftProposalPathParamsDaoEnumKey, | ||
| draftId, | ||
| ) | ||
| void getDraft(draftId) |
There was a problem hiding this comment.
Reject drafts from a different DAO
Because this new User API lookup is global by draft id, the response can be for a different DAO than the current route; the returned shared.daoId is ignored and the form is hydrated/published using the route's daoIdEnum. If an ENS draft id is opened under /shu/proposals/new?draftId=..., the ENS draft contents can be edited/shared/published as a SHU proposal. Check shared.daoId against daoId before resetting the form (or redirect to the draft's DAO).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cbc3db9eb8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const signInWithGoogle = () => | ||
| authClient.signIn.social({ | ||
| provider: "google", | ||
| callbackURL: window.location.href, | ||
| }); |
There was a problem hiding this comment.
Route Google OAuth callbacks through the proxy
When NEXT_PUBLIC_GOOGLE_LOGIN=true, this starts Better Auth's hosted OAuth flow, but the User API mounts Better Auth at /api/auth/* and its BETTER_AUTH_URL is the dashboard origin, while this PR only adds a Next proxy for /api/user/*. After the provider redirects back to /api/auth/callback/google, the dashboard has no route for that path (and middleware skips api), so Google sign-in reaches a 404 after successful provider auth; add an /api/auth/* proxy/redirect or configure callbacks to use /api/user/api/auth/*.
Useful? React with 👍 / 👎.
| } catch { | ||
| if (cancelled) return; | ||
| // Migration failed — fall back to local drafts and surface an error so | ||
| // the UI can offer a manual retry. The lock stays unset so a later | ||
| // retry (via the `retry()` callback) can replay the migration. | ||
| setDrafts(localDrafts); | ||
| setError(true); | ||
| if (!cancelled) setError(true); |
There was a problem hiding this comment.
Clear stale drafts when loading fails
If listDrafts(daoId) fails after the user has already loaded drafts for another DAO/session, this catch only flips error and leaves the previous drafts array in state. GovernanceSection treats draftsError && drafts.length !== 0 as a warning and still renders/maps those rows, so an API error during a DAO switch can show/share/edit stale drafts under the current DAO; clear drafts at the start of a new load or on failure.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 71eb87c7ec
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (drafts.needsAuth) { | ||
| openLogin(); | ||
| return; |
There was a problem hiding this comment.
Gate the insufficient-VP modal on a saved draft
When a connected wallet has no Better Auth session and clicks Publish with voting power below the threshold, handlePublishClick calls handleSaveDraft({ navigateToDrafts: false }) and then immediately opens InsufficientVPModal, whose copy says the draft has been saved and whose “View draft” action routes to the drafts tab. This new auth guard only opens the login modal and returns without saving, so that flow can show a false saved-draft confirmation and send the user to an empty/missing draft; make the publish path wait for authentication/save success before opening the insufficient-VP modal.
Useful? React with 👍 / 👎.
…-auth - the three connect-wallet buttons called RainbowKit's openConnectModal directly, so the custom LoginModal never appeared; they now go through useLogin().openLogin - the better-auth client baseURL was missing the service's /api/auth base path, so every auth call (get-session, siwe/nonce, ...) 404'd through the /api/user proxy - the modal shows the ANTICAPTURE wordmark per the Figma (whitelabel keeps the neutral mark); the wordmark SVG now uses currentColor, with the watermark call-site compensating for its previously baked-in opacity Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a7335cac67
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| const dao = daoId as GetDraftProposalsPathParamsDaoEnumKey; | ||
| const userId = session?.user.id ?? null; | ||
| const needsAuth = !isSessionPending && !userId; |
There was a problem hiding this comment.
Allow session-only users into proposal creation
Because drafts are now keyed to the Better Auth session, an email/Google user can be authenticated while useAccount().isConnected remains false, but the primary New Proposal CTA still only routes when isConnected and otherwise opens RainbowKit directly (apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx:351-356). In that session-only case the user cannot reach the editor to create or save a User API draft from the CTA; gate that flow on the auth session/login state rather than wagmi connection alone.
Useful? React with 👍 / 👎.
| const ownsDraft = Boolean(ownedDraft); | ||
| const isRecipient = Boolean(draftId) && !drafts.isLoading && !ownsDraft; | ||
| // Editor only for a new proposal or an owned draft (no load-dependent flash). | ||
| const canShowEditor = !draftId || ownsDraft; |
There was a problem hiding this comment.
Honor shared ownership when the drafts list fails
When the private listDrafts request fails but the public getDraft() request still returns shared.isOwner === true, this ownership is only saved into currentDraftId; the editor gate below still depends solely on finding the row in drafts.getDraft(draftId). In that error context an owner opening their own share link is classified as a recipient and cannot edit/update the draft, so include the server isOwner result in the ownership state used by canShowEditor.
Useful? React with 👍 / 👎.
Summary
Dashboard side of the platform-accounts architecture (stacked on #2044 — the
apps/user-apiservice). Adds the sign-in modal from the Figma design and moves draft proposals onto the session-scoped User API.Changes
/api/userproxy: same-origin, cookie-relaying reverse proxy to the User API (everySet-Cookiepreserved; forwardsOrigin+x-forwarded-host, which better-auth needs behind a path-rewriting proxy). Unlike the gateful proxy, no shared bearer is attached — identity is the user's own session.useSiweLogin— opens RainbowKit connect when needed, resumes after connect), email (magic link with enter-email → “Check your inbox” + 30s resend cooldown), Google (social sign-in). Email/Google render only where enabled (NEXT_PUBLIC_EMAIL_LOGIN/NEXT_PUBLIC_GOOGLE_LOGIN); whitelabel is wallet-only. Mounted app-wide viaLoginProvider(useLogin().openLogin()).Modalgains an optional header — headerless modals render a bare close button + screen-reader-only name (ariaLabel), so bespoke layouts like the login surface reuse the same dialog./api/user/draftsclient (the User API is not in gateful's spec, so no generated SDK).useDraftsis session-gated (useSession), sends no address params, and exposesneedsAuth;ProposalCreationFormuses the server-derivedisOwnerfor Editor-vs-Preview (correct for email/Google authors, who have no wallet), and Save prompts sign-in when unauthenticated. Removes deaduseDraftRecipient.Verification
pnpm typecheckclean; eslint clean on changed files; 63 create-proposal jest tests green.Notes
Refs DEV-978, DEV-979.
🤖 Generated with Claude Code