feat: migrate from NextAuth to pure EncryptID auth
Replace the NextAuth Credentials provider wrapper with direct EncryptID SDK integration. The auth() function now reads the encryptid_token cookie directly, keeping the same interface so all API routes and server components work unchanged. - Replace SessionProvider with EncryptIDProvider - Add /api/auth/session endpoint for cookie management - Update signin/signup pages to use SDK login/register + cookie sync - Update Navbar to use useEncryptID() hook - Remove next-auth, @auth/prisma-adapter, bcryptjs dependencies - Drop Account, Session, VerificationToken Prisma models + DB tables - Remove legacy email/password register route Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ed22c9caa
commit
a9866f4a32
|
|
@ -12,8 +12,8 @@ services:
|
||||||
- "traefik.docker.network=traefik-public"
|
- "traefik.docker.network=traefik-public"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rvote:${DB_PASSWORD}@postgres:5432/rvote
|
- DATABASE_URL=postgresql://rvote:${DB_PASSWORD}@postgres:5432/rvote
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
- ENCRYPTID_SERVER_URL=https://encryptid.jeffemmett.com
|
||||||
- NEXTAUTH_URL=https://rvote.online
|
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://encryptid.jeffemmett.com
|
||||||
- SMTP_HOST=${SMTP_HOST:-mx.jeffemmett.com}
|
- SMTP_HOST=${SMTP_HOST:-mx.jeffemmett.com}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USER=${SMTP_USER:-noreply@jeffemmett.com}
|
- SMTP_USER=${SMTP_USER:-noreply@jeffemmett.com}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,13 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
|
||||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
@ -29,7 +26,6 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
|
||||||
|
|
@ -22,49 +22,10 @@ model User {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// NextAuth fields
|
|
||||||
accounts Account[]
|
|
||||||
sessions Session[]
|
|
||||||
|
|
||||||
// Space memberships
|
// Space memberships
|
||||||
spaceMemberships SpaceMember[]
|
spaceMemberships SpaceMember[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
type String
|
|
||||||
provider String
|
|
||||||
providerAccountId String
|
|
||||||
refresh_token String? @db.Text
|
|
||||||
access_token String? @db.Text
|
|
||||||
expires_at Int?
|
|
||||||
token_type String?
|
|
||||||
scope String?
|
|
||||||
id_token String? @db.Text
|
|
||||||
session_state String?
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Session {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
sessionToken String @unique
|
|
||||||
userId String
|
|
||||||
expires DateTime
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model VerificationToken {
|
|
||||||
identifier String
|
|
||||||
token String @unique
|
|
||||||
expires DateTime
|
|
||||||
|
|
||||||
@@unique([identifier, token])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Spaces (Multi-Tenant) ───────────────────────────────────────────
|
// ─── Spaces (Multi-Tenant) ───────────────────────────────────────────
|
||||||
|
|
||||||
model Space {
|
model Space {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { handlers } from "@/lib/auth";
|
|
||||||
|
|
||||||
export const { GET, POST } = handlers;
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { email, password, name } = await req.json();
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Email and password are required" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid email format" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate password strength
|
|
||||||
if (password.length < 8) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Password must be at least 8 characters" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already exists
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { email: email.toLowerCase() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "An account with this email already exists" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
|
||||||
|
|
||||||
// Create user with initial credits
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: email.toLowerCase(),
|
|
||||||
passwordHash,
|
|
||||||
name: name || null,
|
|
||||||
credits: 50, // Starting credits
|
|
||||||
emailVerified: new Date(), // Auto-verify for now
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
credits: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ user }, { status: 201 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Registration error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to create account" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { verifyEncryptIDToken } from '@encryptid/sdk/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const SERVER_URL =
|
||||||
|
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/session — Verify EncryptID token and set session cookie.
|
||||||
|
* Called by signin/signup pages after successful WebAuthn ceremony.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token } = await request.json();
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Token required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifyEncryptIDToken(token, { serverUrl: SERVER_URL });
|
||||||
|
const did: string | undefined = claims.did || claims.sub;
|
||||||
|
if (!did) {
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert user in DB
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { did },
|
||||||
|
update: { name: claims.username || undefined },
|
||||||
|
create: {
|
||||||
|
did,
|
||||||
|
email: `${did}@encryptid.local`,
|
||||||
|
name: claims.username || null,
|
||||||
|
credits: 50,
|
||||||
|
emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
did: user.did,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set('encryptid_token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/auth/session — Clear session cookie (sign out).
|
||||||
|
*/
|
||||||
|
export async function DELETE() {
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
response.cookies.set('encryptid_token', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Loader2, Fingerprint } from "lucide-react";
|
import { Loader2, Fingerprint } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { EncryptIDClient } from "@encryptid/sdk/client";
|
import { useEncryptID } from "@encryptid/sdk/ui/react";
|
||||||
|
|
||||||
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
|
|
||||||
const encryptid = new EncryptIDClient(ENCRYPTID_SERVER);
|
|
||||||
|
|
||||||
function SignInForm() {
|
function SignInForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
||||||
|
const { login } = useEncryptID();
|
||||||
|
|
||||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -24,22 +21,22 @@ function SignInForm() {
|
||||||
setIsPasskeyLoading(true);
|
setIsPasskeyLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// SDK handles the full WebAuthn ceremony
|
// SDK handles the full WebAuthn ceremony + stores token in localStorage
|
||||||
const authResult = await encryptid.authenticate();
|
await login();
|
||||||
|
|
||||||
// Exchange EncryptID token for NextAuth session
|
// Get the token from localStorage and set it as a cookie for SSR
|
||||||
const result = await signIn("encryptid", {
|
const token = localStorage.getItem("encryptid_token");
|
||||||
token: authResult.token,
|
if (token) {
|
||||||
redirect: false,
|
await fetch("/api/auth/session", {
|
||||||
});
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
if (result?.error) {
|
body: JSON.stringify({ token }),
|
||||||
toast.error("Passkey verified but session creation failed");
|
});
|
||||||
} else {
|
|
||||||
toast.success("Signed in with passkey!");
|
|
||||||
router.push(callbackUrl);
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Signed in with passkey!");
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === "NotAllowedError") {
|
if (error.name === "NotAllowedError") {
|
||||||
toast.error("No passkey found. Create an account first.");
|
toast.error("No passkey found. Create an account first.");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -10,13 +9,11 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Loader2, Fingerprint } from "lucide-react";
|
import { Loader2, Fingerprint } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { EncryptIDClient } from "@encryptid/sdk/client";
|
import { useEncryptID } from "@encryptid/sdk/ui/react";
|
||||||
|
|
||||||
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
|
|
||||||
const encryptid = new EncryptIDClient(ENCRYPTID_SERVER);
|
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { register } = useEncryptID();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
@ -27,23 +24,22 @@ export default function SignUpPage() {
|
||||||
try {
|
try {
|
||||||
const username = name.trim() || `user-${Date.now().toString(36)}`;
|
const username = name.trim() || `user-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
// SDK handles the full WebAuthn registration ceremony
|
// SDK handles the full WebAuthn registration ceremony + stores token
|
||||||
const regResult = await encryptid.register(username, name || username);
|
await register(username, name || username);
|
||||||
|
|
||||||
// Exchange EncryptID token for NextAuth session
|
// Get the token from localStorage and set it as a cookie for SSR
|
||||||
const result = await signIn("encryptid", {
|
const token = localStorage.getItem("encryptid_token");
|
||||||
token: regResult.token,
|
if (token) {
|
||||||
redirect: false,
|
await fetch("/api/auth/session", {
|
||||||
});
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
if (result?.error) {
|
body: JSON.stringify({ token }),
|
||||||
toast.error("Passkey registered but session creation failed");
|
});
|
||||||
router.push("/auth/signin");
|
|
||||||
} else {
|
|
||||||
toast.success("Account created with passkey! Welcome to rVote.");
|
|
||||||
router.push("/");
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Account created with passkey! Welcome to rVote.");
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === "NotAllowedError") {
|
if (error.name === "NotAllowedError") {
|
||||||
toast.error("Passkey registration was cancelled");
|
toast.error("Passkey registration was cancelled");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { EncryptIDProvider } from '@encryptid/sdk/ui/react';
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
// Cast to any to bypass React 18/19 type mismatch between SDK and app
|
||||||
|
const Provider = EncryptIDProvider as any;
|
||||||
|
return (
|
||||||
|
<Provider serverUrl={process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL}>
|
||||||
|
{children}
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSession, signOut } from "next-auth/react";
|
import { useEncryptID } from "@encryptid/sdk/ui/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CreditDisplay } from "./CreditDisplay";
|
import { CreditDisplay } from "./CreditDisplay";
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { data: session, status } = useSession();
|
const { isAuthenticated, username, did, loading, logout } = useEncryptID();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Hide the main navbar on space pages — SpaceNav handles navigation there
|
// Hide the main navbar on space pages — SpaceNav handles navigation there
|
||||||
|
|
@ -23,6 +23,14 @@ export function Navbar() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
// Clear the server-side cookie
|
||||||
|
await fetch("/api/auth/session", { method: "DELETE" });
|
||||||
|
// Clear client-side state
|
||||||
|
logout();
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
|
|
@ -48,9 +56,9 @@ export function Navbar() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{status === "loading" ? (
|
{loading ? (
|
||||||
<div className="h-8 w-20 animate-pulse bg-muted rounded" />
|
<div className="h-8 w-20 animate-pulse bg-muted rounded" />
|
||||||
) : session?.user ? (
|
) : isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<CreditDisplay />
|
<CreditDisplay />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -61,8 +69,8 @@ export function Navbar() {
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{session.user.name?.[0]?.toUpperCase() ||
|
{username?.[0]?.toUpperCase() ||
|
||||||
session.user.email?.[0]?.toUpperCase() ||
|
did?.[0]?.toUpperCase() ||
|
||||||
"U"}
|
"U"}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
@ -71,12 +79,12 @@ export function Navbar() {
|
||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
<div className="flex items-center justify-start gap-2 p-2">
|
<div className="flex items-center justify-start gap-2 p-2">
|
||||||
<div className="flex flex-col space-y-1 leading-none">
|
<div className="flex flex-col space-y-1 leading-none">
|
||||||
{session.user.name && (
|
{username && (
|
||||||
<p className="font-medium">{session.user.name}</p>
|
<p className="font-medium">{username}</p>
|
||||||
)}
|
)}
|
||||||
{session.user.email && (
|
{did && (
|
||||||
<p className="w-[200px] truncate text-sm text-muted-foreground">
|
<p className="w-[200px] truncate text-sm text-muted-foreground">
|
||||||
{session.user.email}
|
{did}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -91,7 +99,7 @@ export function Navbar() {
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => signOut()}
|
onClick={handleSignOut}
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { AuthProvider } from "./AuthProvider";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
</SessionProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
src/lib/auth.ts
127
src/lib/auth.ts
|
|
@ -1,78 +1,57 @@
|
||||||
import NextAuth from "next-auth";
|
import { verifyEncryptIDToken } from '@encryptid/sdk/server';
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import { cookies } from 'next/headers';
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
import { prisma } from './prisma';
|
||||||
import { prisma } from "./prisma";
|
|
||||||
import { verifyEncryptIDToken } from "./encryptid";
|
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
const SERVER_URL =
|
||||||
adapter: PrismaAdapter(prisma),
|
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
|
||||||
trustHost: true,
|
|
||||||
session: {
|
interface AuthSession {
|
||||||
strategy: "jwt",
|
user: {
|
||||||
},
|
id: string;
|
||||||
pages: {
|
email: string;
|
||||||
signIn: "/auth/signin",
|
name: string | null;
|
||||||
error: "/auth/error",
|
did: string | null;
|
||||||
},
|
};
|
||||||
providers: [
|
}
|
||||||
// EncryptID passkey login — sole auth provider
|
|
||||||
Credentials({
|
/**
|
||||||
id: "encryptid",
|
* Get the current user session.
|
||||||
name: "EncryptID Passkey",
|
* Works in both server components and API route handlers.
|
||||||
credentials: {
|
* Reads the encryptid_token cookie, verifies it, and upserts the user.
|
||||||
token: { label: "Token", type: "text" },
|
*
|
||||||
|
* Drop-in replacement for the old NextAuth `auth()` function.
|
||||||
|
*/
|
||||||
|
export async function auth(): Promise<AuthSession | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('encryptid_token')?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claims = await verifyEncryptIDToken(token, { serverUrl: SERVER_URL });
|
||||||
|
const did: string | undefined = claims.did || claims.sub;
|
||||||
|
if (!did) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { did },
|
||||||
|
update: { name: claims.username || undefined },
|
||||||
|
create: {
|
||||||
|
did,
|
||||||
|
email: `${did}@encryptid.local`,
|
||||||
|
name: claims.username || null,
|
||||||
|
credits: 50,
|
||||||
|
emailVerified: new Date(),
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
});
|
||||||
if (!credentials?.token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the EncryptID JWT
|
return {
|
||||||
const claims = await verifyEncryptIDToken(credentials.token as string);
|
user: {
|
||||||
if (!claims) {
|
id: user.id,
|
||||||
return null;
|
email: user.email,
|
||||||
}
|
name: user.name,
|
||||||
|
did: user.did,
|
||||||
const did = claims.did || claims.sub;
|
|
||||||
|
|
||||||
// Find existing user by DID or create a new one
|
|
||||||
let user = await prisma.user.findFirst({
|
|
||||||
where: { did },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Create new passkey-only user
|
|
||||||
user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email: `${did}@encryptid.local`, // Placeholder email for DID-only users
|
|
||||||
did,
|
|
||||||
name: claims.username || null,
|
|
||||||
credits: 50, // Starting credits
|
|
||||||
emailVerified: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
],
|
} catch {
|
||||||
callbacks: {
|
return null;
|
||||||
async jwt({ token, user }) {
|
}
|
||||||
if (user) {
|
}
|
||||||
token.id = user.id;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
async session({ session, token }) {
|
|
||||||
if (session.user) {
|
|
||||||
session.user.id = token.id as string;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/**
|
|
||||||
* EncryptID configuration for rvote-online
|
|
||||||
*
|
|
||||||
* Uses @encryptid/sdk for token verification instead of manual HTTP calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { verifyEncryptIDToken as sdkVerify } from '@encryptid/sdk/server';
|
|
||||||
|
|
||||||
export const ENCRYPTID_SERVER_URL =
|
|
||||||
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify an EncryptID JWT token.
|
|
||||||
* Returns claims if valid, null if invalid.
|
|
||||||
*/
|
|
||||||
export async function verifyEncryptIDToken(token: string): Promise<{
|
|
||||||
sub: string;
|
|
||||||
username?: string;
|
|
||||||
did?: string;
|
|
||||||
exp?: number;
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
const claims = await sdkVerify(token, {
|
|
||||||
serverUrl: ENCRYPTID_SERVER_URL,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
sub: claims.sub,
|
|
||||||
username: claims.username,
|
|
||||||
did: claims.did,
|
|
||||||
exp: claims.exp,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Space Role bridge for rVote
|
* Space Role bridge for rVote
|
||||||
*
|
*
|
||||||
* Bridges NextAuth session + EncryptID SDK SpaceRole system.
|
* Bridges EncryptID auth + SDK SpaceRole system.
|
||||||
* Resolves the user's effective SpaceRole in the current space
|
* Resolves the user's effective SpaceRole in the current space
|
||||||
* by querying the EncryptID membership server.
|
* by querying the EncryptID membership server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { auth } from './auth';
|
|
||||||
import { prisma } from './prisma';
|
import { prisma } from './prisma';
|
||||||
import {
|
import {
|
||||||
SpaceRole,
|
SpaceRole,
|
||||||
|
|
@ -14,7 +13,6 @@ import {
|
||||||
type ResolvedRole,
|
type ResolvedRole,
|
||||||
} from '@encryptid/sdk/types';
|
} from '@encryptid/sdk/types';
|
||||||
import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
|
import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
|
||||||
import type { Session } from 'next-auth';
|
|
||||||
|
|
||||||
const ENCRYPTID_SERVER = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
|
const ENCRYPTID_SERVER = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
|
||||||
|
|
||||||
|
|
@ -105,7 +103,7 @@ export async function resolveUserSpaceRole(
|
||||||
* Check if the current session user has a specific rVote capability.
|
* Check if the current session user has a specific rVote capability.
|
||||||
*/
|
*/
|
||||||
export async function checkVoteCapability(
|
export async function checkVoteCapability(
|
||||||
session: Session | null,
|
session: { user: { id: string } } | null,
|
||||||
spaceSlug: string,
|
spaceSlug: string,
|
||||||
capability: keyof typeof RVOTE_PERMISSIONS.capabilities,
|
capability: keyof typeof RVOTE_PERMISSIONS.capabilities,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { DefaultSession } from "next-auth";
|
|
||||||
|
|
||||||
declare module "next-auth" {
|
|
||||||
interface Session {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
did?: string | null;
|
|
||||||
} & DefaultSession["user"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue