diff --git a/package-lock.json b/package-lock.json index fb5aa90..605330b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "2.1.1", "lucide-react": "0.441.0", "next": "14.2.5", + "next-auth": "^5.0.0-beta.32", "react": "18.3.1", "react-dom": "18.3.1", "tailwindcss": "3.4.9", @@ -50,6 +51,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1079,6 +1109,15 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5549,6 +5588,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6228,6 +6276,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6311,6 +6386,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6921,6 +7005,25 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 596bc3b..cc83539 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "clsx": "2.1.1", "lucide-react": "0.441.0", "next": "14.2.5", + "next-auth": "^5.0.0-beta.32", "react": "18.3.1", "react-dom": "18.3.1", "tailwindcss": "3.4.9", diff --git a/scripts/add-member.ts b/scripts/add-member.ts index 74c7c04..183975f 100644 --- a/scripts/add-member.ts +++ b/scripts/add-member.ts @@ -9,7 +9,13 @@ * Usage: * npx tsx scripts/add-member.ts --org orangecat --name "George" \ * --type HUMAN --address [--pubkey ] \ - * [--system orangecat:cat] [--weight 1] [--mint-key] + * [--system orangecat:cat] [--weight 1] [--mint-key] \ + * [--oc-actor ] + * + * --oc-actor (humans only) links the member to an OrangeCat identity so + * "Sign in with OrangeCat" recognizes them. The actor id is shown on + * /account after the person signs in — recognition only; voting authority + * stays with the Bitcoin key regardless. * * --mint-key (agents only) generates a transport API key, stores its sha256, * and prints the plaintext ONCE — it is never stored or shown again. @@ -29,6 +35,7 @@ const { values } = parseArgs({ system: { type: "string" }, weight: { type: "string", default: "1" }, "mint-key": { type: "boolean", default: false }, + "oc-actor": { type: "string" }, }, }); @@ -46,6 +53,10 @@ async function main() { console.error("agents need --system (e.g. orangecat:cat) — which system attests this member's votes"); process.exit(1); } + if (type === "AGENT" && values["oc-actor"]) { + console.error("--oc-actor is for HUMAN members only — agents are recognized by their API key, not a login"); + process.exit(1); + } const organization = await prisma.organization.findUnique({ where: { slug: org } }); if (!organization) { @@ -73,6 +84,7 @@ async function main() { publicKeyHex: values.pubkey ?? null, votingWeight: values.weight, system: values.system ?? null, + ocActorId: values["oc-actor"] ?? null, }, }); await tx.auditEvent.create({ @@ -86,6 +98,7 @@ async function main() { memberType: type, bitcoinAddress: address, ...(values.system ? { system: values.system } : {}), + ...(values["oc-actor"] ? { ocActorId: values["oc-actor"] } : {}), note: "operator bootstrap — roster changes after genesis go through MEMBERSHIP votes", }, }, diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 0000000..76a9157 --- /dev/null +++ b/src/app/account/page.tsx @@ -0,0 +1,130 @@ +import Link from "next/link"; +import { auth, signIn, signOut, authEnabled } from "@/lib/auth"; +import { memberForActor } from "@/lib/auth/recognition"; + +export const metadata = { title: "Account — Solon" }; +export const dynamic = "force-dynamic"; + +/** + * The one personal page. It answers exactly two questions — who does + * OrangeCat say you are, and are you a voting member — and is honest about + * the boundary between them: membership is granted by a vote (or the + * documented operator bootstrap), never by signing up. + */ +export default async function AccountPage() { + const session = await auth(); + + if (!session?.actorId) { + return ( +
+

Account

+

+ Solon has no accounts of its own — no passwords, no registration. + Sign in with OrangeCat to be recognized; voting itself never needs + a login, only a Bitcoin signature. +

+ {authEnabled ? ( +
{ + "use server"; + await signIn("orangecat", { redirectTo: "/account" }); + }} + > + +
+ ) : ( +

+ Sign-in is not configured in this environment. +

+ )} +
+ ); + } + + const member = await memberForActor(session.actorId); + + return ( +
+

Account

+ +
+

+ OrangeCat identity +

+
+
+
Name
+
{session.user?.name ?? "—"}
+
+
+
Email
+
{session.user?.email ?? "—"}
+
+
+
Actor id
+
{session.actorId}
+
+
+
+ +
+

+ Governance membership +

+ {member ? ( +
+
+
Member
+
{member.displayName}
+
+
+
Organization
+
{member.organization.name}
+
+
+
Voting weight
+
{member.votingWeight.toString()}
+
+
+
Bitcoin address
+
+ {member.bitcoinAddress} +
+
+
+ ) : ( +

+ You are signed in as an observer. Membership in a Solon + organization is granted by a governance vote, not by signing up — + there is nothing to register here. Everything on this site is + already fully readable to you. +

+ )} +
+ +
+
{ + "use server"; + await signOut({ redirectTo: "/" }); + }} + > + +
+ + Go to voting + +
+
+ ); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c55a45e --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/src/app/auth/error/page.tsx b/src/app/auth/error/page.tsx new file mode 100644 index 0000000..fcaec52 --- /dev/null +++ b/src/app/auth/error/page.tsx @@ -0,0 +1,56 @@ +import Link from "next/link"; + +export const metadata = { title: "Sign-in problem — Solon" }; + +/** + * NextAuth redirects here with ?error=. The case worth a real + * explanation is AccessDenied: our signIn callback rejects OrangeCat + * accounts without an email (OC's anonymous "start instantly" accounts), + * because a governance identity must be attributable. + */ +export default function AuthErrorPage({ + searchParams, +}: { + searchParams: { error?: string }; +}) { + const denied = searchParams.error === "AccessDenied"; + return ( +
+

+ {denied ? "This OrangeCat account can’t be recognized" : "Sign-in didn’t complete"} +

+ {denied ? ( +
+

+ Your OrangeCat account has no email address — it is an anonymous + account. Solon is a governance system: every recognized identity + must be attributable, so anonymous accounts can’t sign in here. +

+

+ Add an email to your account at{" "} + + orangecat.ch/settings + {" "} + and try again. Note that you never need to sign in to observe — + all governance data on this site is public — or to vote, which + works by Bitcoin signature alone. +

+
+ ) : ( +

+ Something went wrong talking to OrangeCat. Try again from the + navigation bar; if it keeps failing, the audit trail and all + governance data remain fully readable without signing in. +

+ )} +
+ + Back to Solon + +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cec0100..b76ffc0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,10 @@ import "./globals.css"; import type { Metadata } from "next"; import { Inter, Space_Grotesk } from "next/font/google"; +import { SessionProvider } from "next-auth/react"; import Navigation from "@/components/ui/navigation"; import Footer from "@/components/ui/footer"; +import { authEnabled } from "@/lib/auth"; const inter = Inter({ subsets: ["latin"], @@ -42,9 +44,13 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - -
{children}
-