-
Notifications
You must be signed in to change notification settings - Fork 0
Feature: Add Google OAuth for Leaderboard Name Claiming #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/convex-leaderboard
Are you sure you want to change the base?
Changes from all commits
95272a3
7f2631a
6b402ce
1665a9a
545524b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import React from "react"; | ||
| import { useAuthActions } from "@convex-dev/auth/react"; | ||
| import { useQuery, useMutation } from "convex/react"; | ||
| import { api } from "../convex/_generated/api"; | ||
|
|
||
| export function UserMenu() { | ||
| const { signIn, signOut } = useAuthActions(); | ||
| // We use ts-ignore because we don't have a configured project for codegen. | ||
| // @ts-ignore | ||
| const userDetails = useQuery(api.users.currentUserDetails); | ||
|
|
||
| return ( | ||
| <div className="absolute top-4 right-4 flex items-center gap-2 z-50"> | ||
| {userDetails !== undefined ? ( | ||
| userDetails === null ? ( | ||
| <button | ||
| onClick={() => void signIn("google")} | ||
| className="text-xs text-[var(--secondary-text-color)] hover:text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity bg-transparent border-none" | ||
| style={{ padding: '0.25rem 0.5rem' }} | ||
| > | ||
| Sign In | ||
| </button> | ||
| ) : ( | ||
| <div className="flex items-center gap-4 bg-black/20 rounded-full pl-2 pr-4 py-1.5 shadow-sm border border-white/5"> | ||
| <a | ||
| href="/profile" | ||
| className="flex items-center gap-2 hover:opacity-80 transition-opacity" | ||
| title="Go to profile" | ||
| > | ||
| {userDetails.image && ( | ||
| <img | ||
| src={userDetails.image} | ||
| alt={userDetails.name || "User"} | ||
| className="w-6 h-6 rounded-full object-cover border border-white/20" | ||
| /> | ||
| )} | ||
| <span className="text-sm text-[var(--text-color)] font-medium"> | ||
| {userDetails.name || "Player"} | ||
| </span> | ||
| </a> | ||
| <button | ||
| onClick={() => void signOut()} | ||
| className="text-xs text-[var(--secondary-text-color)] hover:text-red-400 transition" | ||
| > | ||
| Sign Out | ||
| </button> | ||
| </div> | ||
| ) | ||
| ) : ( | ||
| <div className="w-24 h-10 bg-gray-700 animate-pulse rounded"></div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| export default { | ||
| providers: [ | ||
| { | ||
| domain: process.env.CONVEX_SITE_URL, | ||
| applicationID: "convex", | ||
| }, | ||
| ], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import Google from "@auth/core/providers/google"; | ||
| import { convexAuth } from "@convex-dev/auth/server"; | ||
|
|
||
| export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ | ||
| providers: [Google], | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { httpRouter } from "convex/server"; | ||
| import { auth } from "./auth"; | ||
|
|
||
| const http = httpRouter(); | ||
|
|
||
| auth.addHttpRoutes(http); | ||
|
|
||
| export default http; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,30 @@ | ||
| import { defineSchema, defineTable } from "convex/server"; | ||
| import { v } from "convex/values"; | ||
| import { authTables } from "@convex-dev/auth/server"; | ||
|
|
||
| export default defineSchema({ | ||
| ...authTables, | ||
| users: defineTable({ | ||
| name: v.optional(v.string()), | ||
| image: v.optional(v.string()), | ||
| email: v.optional(v.string()), | ||
| emailVerificationTime: v.optional(v.number()), | ||
| phone: v.optional(v.string()), | ||
| phoneVerificationTime: v.optional(v.number()), | ||
| isAnonymous: v.optional(v.boolean()), | ||
| customDisplayName: v.optional(v.string()), | ||
| }).index("email", ["email"]).index("customDisplayName", ["customDisplayName"]), | ||
| leaderboard: defineTable({ | ||
| playerName: v.string(), | ||
| userId: v.optional(v.id("users")), | ||
| gameMode: v.string(), // "Classic" or "Custom" | ||
| settingsHash: v.string(), // "classic" for Classic, deterministic key for Custom | ||
| longestCombo: v.number(), | ||
| isPerfectScore: v.boolean(), | ||
| completedAt: v.number(), // timestamp | ||
| }) | ||
| .index("by_settings", ["settingsHash", "longestCombo"]) | ||
| .index("by_mode", ["gameMode", "longestCombo"]), | ||
| .index("by_mode", ["gameMode", "longestCombo"]) | ||
| .index("by_userId", ["userId"]) | ||
| .index("by_playerName", ["playerName"]), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { mutation, query } from "./_generated/server"; | ||
| import { v } from "convex/values"; | ||
| import { getAuthUserId } from "@convex-dev/auth/server"; | ||
|
|
||
| export const checkNameClaimed = query({ | ||
| args: { | ||
| name: v.string(), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| const trimmedName = args.name.trim().slice(0, 20); | ||
| const existingUser = await ctx.db | ||
| .query("users") | ||
| .withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName)) | ||
| .first(); | ||
|
|
||
| return existingUser !== null; | ||
| }, | ||
| }); | ||
|
|
||
| export const claimName = mutation({ | ||
| args: { | ||
| name: v.string(), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| const userId = await getAuthUserId(ctx); | ||
| if (userId === null) { | ||
| throw new Error("User must be authenticated to claim a name."); | ||
| } | ||
|
|
||
| const trimmedName = args.name.trim().slice(0, 20); | ||
| if (trimmedName.length === 0) { | ||
| throw new Error("Name cannot be empty"); | ||
| } | ||
|
|
||
| // Check if another user already claimed this name | ||
| const existingUser = await ctx.db | ||
| .query("users") | ||
| .withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName)) | ||
| .first(); | ||
|
|
||
| if (existingUser !== null && existingUser._id !== userId) { | ||
| throw new Error("This name is already claimed by someone else."); | ||
| } | ||
|
|
||
| // Update the user's customDisplayName | ||
| await ctx.db.patch(userId, { customDisplayName: trimmedName, isAnonymous: false }); | ||
|
|
||
| // Migrate existing anonymous scores under this name to the user | ||
| const allScoresForName = await ctx.db | ||
| .query("leaderboard") | ||
| .withIndex("by_playerName", (q) => q.eq("playerName", trimmedName)) | ||
| .collect(); | ||
|
Comment on lines
+49
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This Fix it with Roo Code or mention @roomote and request a fix. |
||
|
|
||
| for (const score of allScoresForName) { | ||
| if (score.userId === undefined) { | ||
| await ctx.db.patch(score._id, { userId }); | ||
| } | ||
| } | ||
|
|
||
| // If the user previously had a different customDisplayName, do we migrate those scores? | ||
| // Actually, scores submitted when authenticated are stored with userId. | ||
| // The playername stored on the record will be historical or we can update it. | ||
| // Let's update the playername on all their past scores to the new name. | ||
| const allUserScores = await ctx.db | ||
| .query("leaderboard") | ||
| .withIndex("by_userId", (q) => q.eq("userId", userId)) | ||
| .collect(); | ||
|
|
||
| for (const score of allUserScores) { | ||
| if (score.playerName !== trimmedName) { | ||
| await ctx.db.patch(score._id, { playerName: trimmedName }); | ||
| } | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| export const currentUserDetails = query({ | ||
| args: {}, | ||
| handler: async (ctx) => { | ||
| const userId = await getAuthUserId(ctx); | ||
| if (userId === null) return null; | ||
| const user = await ctx.db.get(userId); | ||
| return { | ||
| name: user?.customDisplayName || null, | ||
| image: user?.image || null, | ||
| }; | ||
| } | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UserMenuis imported here but never rendered in the component's JSX. The sign-in/sign-out UI won't be visible to users, which defeats the purpose of this PR. You likely need to add<UserMenu />somewhere in the returned markup (e.g. alongside the other top-level UI elements).Fix it with Roo Code or mention @roomote and request a fix.