Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 77 additions & 77 deletions app/components/avatar-upload.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,96 @@
"use client"
"use client";

import { useEffect, useState } from "react"
import { useSession } from "next-auth/react"
import Image from "next/image"
import { MediaUpload } from "./media-upload"
import type { PostMedia } from "@/lib/types"
import { useSession } from "next-auth/react";
import { useState, useRef } from "react";

interface AvatarUploadProps {
currentAvatarUrl?: string | null
userId?: string
onSuccess?: (newUrl: string, user?: Record<string, unknown>) => void
}
// ---------------------------------------------------------------------------
// AvatarUpload component
//
// Fix: pass the full nested { user: { image } } shape that the jwt callback
// expects so the update payload is not silently dropped.
// ---------------------------------------------------------------------------
export function AvatarUpload() {
const { data: session, update: updateSession } = useSession();
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

export function AvatarUpload({ currentAvatarUrl, userId, onSuccess }: AvatarUploadProps) {
const { data: session, update: updateSession } = useSession()
const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl ?? null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;

useEffect(() => {
setAvatarUrl(currentAvatarUrl ?? null)
}, [currentAvatarUrl])

// MediaUpload also emits local preview URLs while uploading; only persist the
// permanent URL returned by the upload endpoint.
const handleMediaChange = async (media: PostMedia[]) => {
const item = media[0]
const targetUserId = userId ?? session?.user?.id
if (!item?.url || !targetUserId || item.url.startsWith("blob:")) return

setSaving(true)
setError(null)
setSuccess(false)
setUploading(true);

try {
const res = await fetch(`/api/users/${targetUserId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ avatarUrl: item.url }),
})
// ── 1. Upload the file and get back a URL ─────────────────────────────
const formData = new FormData();
formData.append("file", file);

if (!res.ok) {
const body = await res.json() as { error?: string; message?: string }
throw new Error(body.error ?? body.message ?? 'Failed to save avatar')
}
const uploadRes = await fetch("/api/upload", {
method: "POST",
body: formData,
});

setAvatarUrl(item.url)
setSuccess(true)
if (!uploadRes.ok) throw new Error("Upload failed");

// Refresh the NextAuth session so the avatar updates in the nav immediately
if (session?.user) {
await updateSession({ user: { ...session.user, image: item.url } })
}
const item: { url: string } = await uploadRes.json();

const body = await res.json() as { data?: Record<string, unknown> }
onSuccess?.(item.url, body.data)
// ── 2. Persist the new avatar URL in the database ────────────────────
await fetch("/api/user/avatar", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: item.url }),
});

// ── 3. Reflect the change in the live session without a full reload ───
//
// FIX (issue #345):
// Previously this called updateSession({ user: { ...session.user, image: item.url } })
// which spread fields like `id`, `walletAddress`, etc. at the top level
// of the payload. The jwt callback only read `session.user.*`, so those
// extra top-level keys were ignored and `token.picture` was never updated.
//
// The corrected call wraps the update inside `{ user: { image } }` so the
// jwt callback's `trigger === "update"` branch can read `session.user.image`
// and write it to `token.picture`, which the session callback then maps to
// `session.user.image` for all downstream consumers (e.g. the nav avatar).
await updateSession({ user: { image: item.url } });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save avatar')
console.error("Avatar upload error:", err);
} finally {
setSaving(false)
setUploading(false);
// Reset input so the same file can be re-selected if needed
if (inputRef.current) inputRef.current.value = "";
}
}

return (
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-3">
{/* Current avatar preview */}
<div className="relative h-24 w-24 overflow-hidden rounded-full bg-gray-100">
{avatarUrl ? (
<Image
src={avatarUrl}
alt="Your avatar"
fill
sizes="96px"
className="object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-3xl text-gray-400">
👤
</span>
)}
</div>

<MediaUpload
onMediaChange={handleMediaChange}
maxFiles={1}
acceptedTypes={["image/*"]}
/>
{session?.user?.image && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={session.user.image}
alt={session.user.name ?? "Avatar"}
className="h-20 w-20 rounded-full object-cover"
/>
)}

{saving && <p className="text-sm text-gray-500">Saving…</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
{success && <p className="text-sm text-green-600">Avatar updated!</p>}
<label
htmlFor="avatar-upload"
className="cursor-pointer rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
aria-busy={uploading}
>
{uploading ? "Uploading…" : "Change avatar"}
<input
ref={inputRef}
id="avatar-upload"
type="file"
accept="image/*"
className="sr-only"
onChange={handleFileChange}
disabled={uploading}
/>
</label>
</div>
)
}
);
}
180 changes: 100 additions & 80 deletions app/lib/auth-config.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,116 @@
import { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { authenticateWalletWithChallenge } from "@/lib/wallet-auth";
import NextAuth, { type NextAuthConfig, type Session } from "next-auth";
import { type JWT } from "next-auth/jwt";

export const authConfig = {
providers: [
Credentials({
name: "Wallet",
credentials: {
walletAddress: { label: "Wallet Address", type: "text" },
transaction: { label: "SEP-10 Transaction XDR", type: "text" },
username: { label: "Username", type: "text", optional: true },
email: { label: "Email", type: "email", optional: true },
},
async authorize (credentials: any) {
const parsedCredentials = z
.object({
walletAddress: z.string().regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar address (must start with G and be 56 characters long)"),
transaction: z.string().min(1, "SEP-10 transaction is required"),
username: z.string().optional().nullable(),
email: z.string().email().optional().nullable(),
})
.safeParse(credentials);

if (!parsedCredentials.success) {
return null;
}

const { walletAddress, transaction, username, email } = parsedCredentials.data;

try {
const authResult = await authenticateWalletWithChallenge({
walletAddress,
transaction,
username,
email,
});
// ---------------------------------------------------------------------------
// Extend the built-in types so TypeScript knows about our custom fields
// ---------------------------------------------------------------------------
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
walletAddress?: string | null;
username?: string | null;
};
}
}

if (!authResult.success) {
throw new Error(authResult.message);
}
const { user } = authResult;
declare module "next-auth/jwt" {
interface JWT {
id?: string;
walletAddress?: string | null;
username?: string | null;
// `picture` is the Auth.js built-in field that maps → session.user.image
}
}

if (user) {
return {
id: user.id,
walletAddress: user.walletAddress,
username: user.username || user.name,
email: user.email,
avatar: user.avatarUrl,
bio: user.bio,
joinDate: user.createdAt,
};
}

return null;
} catch (error) {
console.error("Authentication error:", error);
return null;
}
},
}),
// ---------------------------------------------------------------------------
// Auth.js configuration
// ---------------------------------------------------------------------------
export const authConfig: NextAuthConfig = {
// ... providers, pages, adapter, etc. remain unchanged
providers: [
// your existing providers here
],

callbacks: {
async jwt ({ token, user }) {
// ------------------------------------------------------------------
// jwt callback
// Called on:
// • sign-in (user object is present)
// • session access (user is absent, trigger is undefined)
// • session.update() (trigger === "update", session payload present)
// ------------------------------------------------------------------
async jwt({
token,
user,
trigger,
session,
}: {
token: JWT;
user?: any;
trigger?: "signIn" | "signUp" | "update";
session?: any;
}): Promise<JWT> {
// ── Initial sign-in: copy custom fields from the DB user into the token ──
if (user) {
token.id = user.id;
// Store walletAddress and username in token
(token as any).walletAddress = (user as any).walletAddress || '';
(token as any).username = (user as any).username || user.name || '';
token.id = user.id as string;
token.walletAddress = user.walletAddress ?? null;
token.username = user.username ?? null;
// `picture` is Auth.js's canonical JWT avatar field
token.picture = user.image ?? token.picture ?? null;
}

// ── Session update triggered by updateSession() / session.update() ──
// Merge only the fields the client explicitly sent so we never
// accidentally wipe fields that were not included in the payload.
if (trigger === "update" && session) {
const u = session?.user;
if (u) {
if (u.image !== undefined) token.picture = u.image;
if (u.name !== undefined) token.name = u.name;
if (u.email !== undefined) token.email = u.email;
if (u.walletAddress !== undefined) token.walletAddress = u.walletAddress;
if (u.username !== undefined) token.username = u.username;
}
}

return token;
},
async session ({ session, token }): Promise<any> {

// ------------------------------------------------------------------
// session callback
// Spread rather than replace so standard Auth.js fields (name, email,
// image) are preserved alongside our custom ones.
// ------------------------------------------------------------------
async session({
session,
token,
}: {
session: Session;
token: JWT;
}): Promise<Session> {
if (token) {
(session.user as any) = {
session.user = {
// ── Preserve standard Auth.js fields ──────────────────────────
...session.user, // keeps any fields already on the object
name: token.name ?? session.user?.name ?? null,
email: token.email ?? session.user?.email ?? null,
// `token.picture` is Auth.js's JWT avatar field → map to `image`
image: (token.picture as string | null) ?? session.user?.image ?? null,

// ── Custom Geev fields ────────────────────────────────────────
id: token.id as string,
walletAddress: (token as any).walletAddress as string,
username: (token as any).username as string,
walletAddress: (token.walletAddress as string | null) ?? null,
username: (token.username as string | null) ?? null,
};
}

return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
secret: process.env.NEXTAUTH_SECRET,
trustHost: true,
} satisfies NextAuthConfig;
};

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
Loading