From 33486f5936b6a38c2611880b21eba8835b2d52f1 Mon Sep 17 00:00:00 2001 From: phisn Date: Tue, 2 Apr 2024 01:00:31 +0200 Subject: [PATCH] Refactor authentication --- .../rust-game/src/player_plugin/camera.rs | 2 +- .../leaderboard}/leaderboard-router.ts | 4 +- .../leaderboard}/validate-replay.ts | 6 +- .../user/user-router.ts} | 104 +++++++++++--- .../src/{query => api/world}/world-router.ts | 6 +- .../src/domain/auth/jwt-creation-token.ts | 4 + packages/server/src/domain/auth/jwt-token.ts | 4 + packages/server/src/domain/auth/user.ts | 3 + .../server/src/{ => framework}/db-schema.ts | 4 +- .../helper/user-from-authorization-header.ts | 27 ++++ packages/server/src/framework/trpc-router.ts | 14 ++ packages/server/src/framework/trpc.ts | 49 +++++++ packages/server/src/index.ts | 6 +- packages/server/src/trpc-router.ts | 14 -- packages/server/src/trpc.ts | 40 ------ .../web/src/app/campaign/WorldSelection.tsx | 3 +- packages/web/src/app/layout/AuthButton.tsx | 127 ------------------ packages/web/src/app/layout/Layout.tsx | 2 +- .../src/app/layout/auth-button/AuthButton.tsx | 76 +++++++++++ .../app/layout/auth-button/CreateAccount.tsx | 93 +++++++++++++ .../src/app/layout/auth-button/LoginBadge.tsx | 34 +++++ .../app/layout/auth-button/RenameAccount.tsx | 84 ++++++++++++ .../runtime-framework/WithEntityStore.tsx | 19 --- .../runtime-framework/use-entity-set.ts | 38 ------ .../runtime-framework/use-entity-tracker.ts | 24 ---- .../common/runtime-framework/use-message.ts | 13 -- packages/web/src/common/storage/app-store.ts | 35 +++-- packages/web/src/common/trpc/trpc-native.ts | 14 +- packages/web/src/common/trpc/trpc.ts | 2 +- 29 files changed, 522 insertions(+), 329 deletions(-) rename packages/server/src/{query => api/leaderboard}/leaderboard-router.ts (94%) rename packages/server/src/{usecase => api/leaderboard}/validate-replay.ts (95%) rename packages/server/src/{query/google-auth.ts => api/user/user-router.ts} (53%) rename packages/server/src/{query => api/world}/world-router.ts (92%) create mode 100644 packages/server/src/domain/auth/jwt-creation-token.ts create mode 100644 packages/server/src/domain/auth/jwt-token.ts create mode 100644 packages/server/src/domain/auth/user.ts rename packages/server/src/{ => framework}/db-schema.ts (91%) create mode 100644 packages/server/src/framework/helper/user-from-authorization-header.ts create mode 100644 packages/server/src/framework/trpc-router.ts create mode 100644 packages/server/src/framework/trpc.ts delete mode 100644 packages/server/src/trpc-router.ts delete mode 100644 packages/server/src/trpc.ts delete mode 100644 packages/web/src/app/layout/AuthButton.tsx create mode 100644 packages/web/src/app/layout/auth-button/AuthButton.tsx create mode 100644 packages/web/src/app/layout/auth-button/CreateAccount.tsx create mode 100644 packages/web/src/app/layout/auth-button/LoginBadge.tsx create mode 100644 packages/web/src/app/layout/auth-button/RenameAccount.tsx delete mode 100644 packages/web/src/common/runtime-framework/WithEntityStore.tsx delete mode 100644 packages/web/src/common/runtime-framework/use-entity-set.ts delete mode 100644 packages/web/src/common/runtime-framework/use-entity-tracker.ts delete mode 100644 packages/web/src/common/runtime-framework/use-message.ts diff --git a/packages/rust-game/src/player_plugin/camera.rs b/packages/rust-game/src/player_plugin/camera.rs index 11441b43..5c231081 100644 --- a/packages/rust-game/src/player_plugin/camera.rs +++ b/packages/rust-game/src/player_plugin/camera.rs @@ -33,7 +33,7 @@ pub struct CameraConfig { impl Default for CameraConfig { fn default() -> Self { Self { - zoom: 1920.0 * 0.02, + zoom: 1920.0 * 0.05, animation_speed: 1.0, } } diff --git a/packages/server/src/query/leaderboard-router.ts b/packages/server/src/api/leaderboard/leaderboard-router.ts similarity index 94% rename from packages/server/src/query/leaderboard-router.ts rename to packages/server/src/api/leaderboard/leaderboard-router.ts index fb2ec2ff..0812e193 100644 --- a/packages/server/src/query/leaderboard-router.ts +++ b/packages/server/src/api/leaderboard/leaderboard-router.ts @@ -1,8 +1,8 @@ import { and, eq } from "drizzle-orm" import { Buffer } from "node:buffer" import { z } from "zod" -import { leaderboard } from "../db-schema" -import { publicProcedure, router } from "../trpc" +import { leaderboard } from "../../framework/db-schema" +import { publicProcedure, router } from "../../framework/trpc" export const leaderboardRouter = router({ get: publicProcedure diff --git a/packages/server/src/usecase/validate-replay.ts b/packages/server/src/api/leaderboard/validate-replay.ts similarity index 95% rename from packages/server/src/usecase/validate-replay.ts rename to packages/server/src/api/leaderboard/validate-replay.ts index 261393df..4cebdb6b 100644 --- a/packages/server/src/usecase/validate-replay.ts +++ b/packages/server/src/api/leaderboard/validate-replay.ts @@ -5,9 +5,9 @@ import { ReplayModel } from "runtime/proto/replay" import { WorldModel } from "runtime/proto/world" import { validateReplay } from "runtime/src/model/replay/validate-replay" import { z } from "zod" -import { leaderboard } from "../db-schema" -import { worlds } from "../domain/worlds" -import { publicProcedure } from "../trpc" +import { worlds } from "../../domain/worlds" +import { leaderboard } from "../../framework/db-schema" +import { publicProcedure } from "../../framework/trpc" export const validateReplayProcedure = publicProcedure .input( diff --git a/packages/server/src/query/google-auth.ts b/packages/server/src/api/user/user-router.ts similarity index 53% rename from packages/server/src/query/google-auth.ts rename to packages/server/src/api/user/user-router.ts index 4a617012..4ec31c83 100644 --- a/packages/server/src/query/google-auth.ts +++ b/packages/server/src/api/user/user-router.ts @@ -3,10 +3,60 @@ import jwt from "@tsndr/cloudflare-worker-jwt" import { eq } from "drizzle-orm" import { OAuth2RequestError } from "oslo/oauth2" import { z } from "zod" -import { users } from "../db-schema" -import { publicProcedure, router } from "../trpc" +import { JwtCreationToken } from "../../domain/auth/jwt-creation-token" +import { JwtToken } from "../../domain/auth/jwt-token" +import { users } from "../../framework/db-schema" +import { publicProcedure, router } from "../../framework/trpc" + +export const userRouter = router({ + me: publicProcedure.query(async ({ ctx: { db, user } }) => { + if (!user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Not logged in", + }) + } + + const [dbUser] = await db.select().from(users).where(eq(users.id, user.id)) + + if (!dbUser) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "User not found", + }) + } + + return { username: dbUser.username } + }), + rename: publicProcedure + .input( + z + .object({ + username: z.string(), + }) + .required(), + ) + .mutation(async ({ input: { username }, ctx: { db, user } }) => { + if (!user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Not logged in", + }) + } + + const duplicate = await db.select().from(users).where(eq(users.username, username)) + + if (duplicate.length > 0) { + return { + message: "username-taken" as const, + } + } + + await db.update(users).set({ username }).where(eq(users.id, user.id)) + console.log("renamed user", user.id, "to", username) -export const googleAuthRouter = router({ + return { message: "success" as const } + }), getToken: publicProcedure .input( z @@ -59,17 +109,18 @@ export const googleAuthRouter = router({ env.JWT_SECRET, ) - return { tokenForCreation, type: "prompt-create" } + return { tokenForCreation, type: "prompt-create" as const } } - const token = await jwt.sign( + const token = await jwt.sign( { - username: user.username, + id: user.id, + iat: Date.now(), }, env.JWT_SECRET, ) - return { token, username: user.username, type: "logged-in" } + return { token, type: "logged-in" as const } } catch (e) { if (e instanceof OAuth2RequestError) { console.error(e) @@ -91,7 +142,7 @@ export const googleAuthRouter = router({ }) .required(), ) - .query(async ({ input: { tokenForCreation, username }, ctx: { env, db } }) => { + .mutation(async ({ input: { tokenForCreation, username }, ctx: { env, db } }) => { const verification = await jwt.verify(tokenForCreation, env.JWT_SECRET) if (verification === false) { @@ -101,7 +152,7 @@ export const googleAuthRouter = router({ }) } - const payload = jwt.decode<{ email: string }>(tokenForCreation).payload + const payload = jwt.decode(tokenForCreation).payload if (!payload) { throw new TRPCError({ @@ -110,20 +161,39 @@ export const googleAuthRouter = router({ }) } + const duplicate = await db.select().from(users).where(eq(users.username, username)) + + if (duplicate.length > 0) { + return { + message: "username-taken" as const, + } + } + const { email } = payload - const token = await jwt.sign( + const [user] = await db + .insert(users) + .values({ + username: username, + email: email, + }) + .returning({ id: users.id }) + + if (!user) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Failed to create user", + }) + } + + const token = await jwt.sign( { - username, + id: user.id, + iat: Date.now(), }, env.JWT_SECRET, ) - await db.insert(users).values({ - username: username, - email: email, - }) - - return { token } + return { message: "success" as const, jwt: token } }), }) diff --git a/packages/server/src/query/world-router.ts b/packages/server/src/api/world/world-router.ts similarity index 92% rename from packages/server/src/query/world-router.ts rename to packages/server/src/api/world/world-router.ts index 4a64475b..a116f75b 100644 --- a/packages/server/src/query/world-router.ts +++ b/packages/server/src/api/world/world-router.ts @@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server" import { asc, eq, inArray } from "drizzle-orm" import { WorldView } from "shared/src/views/world-view" import { z } from "zod" -import { leaderboard } from "../db-schema" -import { worlds } from "../domain/worlds" -import { publicProcedure, router } from "../trpc" +import { worlds } from "../../domain/worlds" +import { leaderboard } from "../../framework/db-schema" +import { publicProcedure, router } from "../../framework/trpc" export const worldRouter = router({ get: publicProcedure diff --git a/packages/server/src/domain/auth/jwt-creation-token.ts b/packages/server/src/domain/auth/jwt-creation-token.ts new file mode 100644 index 00000000..29ec8c95 --- /dev/null +++ b/packages/server/src/domain/auth/jwt-creation-token.ts @@ -0,0 +1,4 @@ +export interface JwtCreationToken { + email: string + iat: number +} diff --git a/packages/server/src/domain/auth/jwt-token.ts b/packages/server/src/domain/auth/jwt-token.ts new file mode 100644 index 00000000..44f980da --- /dev/null +++ b/packages/server/src/domain/auth/jwt-token.ts @@ -0,0 +1,4 @@ +export interface JwtToken { + id: number + iat: number +} diff --git a/packages/server/src/domain/auth/user.ts b/packages/server/src/domain/auth/user.ts new file mode 100644 index 00000000..d7dff1c0 --- /dev/null +++ b/packages/server/src/domain/auth/user.ts @@ -0,0 +1,3 @@ +export interface User { + id: number +} diff --git a/packages/server/src/db-schema.ts b/packages/server/src/framework/db-schema.ts similarity index 91% rename from packages/server/src/db-schema.ts rename to packages/server/src/framework/db-schema.ts index d80d4117..85e8aa5c 100644 --- a/packages/server/src/db-schema.ts +++ b/packages/server/src/framework/db-schema.ts @@ -30,8 +30,8 @@ export const users = sqliteTable("users", { autoIncrement: true, }), - email: text("email").notNull(), - username: text("username").notNull(), + email: text("email").notNull().unique(), + username: text("username").notNull().unique(), }) export type Users = typeof users.$inferSelect diff --git a/packages/server/src/framework/helper/user-from-authorization-header.ts b/packages/server/src/framework/helper/user-from-authorization-header.ts new file mode 100644 index 00000000..bb2b6edb --- /dev/null +++ b/packages/server/src/framework/helper/user-from-authorization-header.ts @@ -0,0 +1,27 @@ +import jwt from "@tsndr/cloudflare-worker-jwt" +import { JwtToken } from "../../domain/auth/jwt-token" +import { User } from "../../domain/auth/user" +import { Env } from "../../env" + +export function userFromAuthorizationHeader(env: Env, authorization: string | null): User | null { + try { + if (authorization && authorization !== "undefined") { + console.log("authorization", authorization) + if (!jwt.verify(authorization, env.JWT_SECRET)) { + return null + } + + const payload = jwt.decode(authorization).payload + + if (!payload) { + return null + } + + return { id: payload.id } + } + } catch (e) { + console.error(e) + } + + return null +} diff --git a/packages/server/src/framework/trpc-router.ts b/packages/server/src/framework/trpc-router.ts new file mode 100644 index 00000000..634b5e24 --- /dev/null +++ b/packages/server/src/framework/trpc-router.ts @@ -0,0 +1,14 @@ +import { leaderboardRouter } from "../api/leaderboard/leaderboard-router" +import { validateReplayProcedure } from "../api/leaderboard/validate-replay" +import { userRouter } from "../api/user/user-router" +import { worldRouter } from "../api/world/world-router" +import { router } from "./trpc" + +export const appRouter = router({ + user: userRouter, + world: worldRouter, + replay: leaderboardRouter, + validateReplay: validateReplayProcedure, +}) + +export type AppRouter = typeof appRouter diff --git a/packages/server/src/framework/trpc.ts b/packages/server/src/framework/trpc.ts new file mode 100644 index 00000000..fd02efa1 --- /dev/null +++ b/packages/server/src/framework/trpc.ts @@ -0,0 +1,49 @@ +import { inferAsyncReturnType, initTRPC } from "@trpc/server" +import { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch" +import { drizzle } from "drizzle-orm/d1" +import { OAuth2Client } from "oslo/oauth2" +import superjson from "superjson" +import { Env } from "../env" +import { userFromAuthorizationHeader } from "./helper/user-from-authorization-header" + +export const createContext = + (env: Env) => + ({ req }: FetchCreateContextFnOptions) => { + const user = userFromAuthorizationHeader(env, req.headers.get("Authorization")) + + return { + db: drizzle(env.DB), + oauth: new OAuth2Client( + env.AUTH_GOOGLE_CLIENT_ID, + "https://accounts.google.com/o/oauth2/auth", + "https://accounts.google.com/o/oauth2/token", + { + redirectURI: `${env.CLIENT_URL}`, + }, + ), + env, + user, + } + } + +type Context = inferAsyncReturnType> + +const t = initTRPC.context().create({ + transformer: superjson, +}) + +export const middleware = t.middleware +export const router = t.router + +export const logger = middleware(async opts => { + try { + // format date as HH:MM:SS + console.log(`Req ${new Date().toISOString()}: ${opts.path}`) + return await opts.next() + } catch (e) { + console.error(e) + throw e + } +}) + +export const publicProcedure = t.procedure.use(logger) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c1bc294a..dbf54a62 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,7 +1,7 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch" import { Env } from "./env" -import { createContext } from "./trpc" -import { appRouter } from "./trpc-router" +import { createContext } from "./framework/trpc" +import { appRouter } from "./framework/trpc-router" export default { async fetch(request: Request, env: Env): Promise { @@ -12,7 +12,7 @@ export default { const headers = { "Access-Control-Allow-Origin": "", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Credentials": "true", } diff --git a/packages/server/src/trpc-router.ts b/packages/server/src/trpc-router.ts deleted file mode 100644 index 0e45140e..00000000 --- a/packages/server/src/trpc-router.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { googleAuthRouter } from "./query/google-auth" -import { leaderboardRouter } from "./query/leaderboard-router" -import { worldRouter } from "./query/world-router" -import { router } from "./trpc" -import { validateReplayProcedure } from "./usecase/validate-replay" - -export const appRouter = router({ - googleAuth: googleAuthRouter, - world: worldRouter, - replay: leaderboardRouter, - validateReplay: validateReplayProcedure, -}) - -export type AppRouter = typeof appRouter diff --git a/packages/server/src/trpc.ts b/packages/server/src/trpc.ts deleted file mode 100644 index 8e7a5b33..00000000 --- a/packages/server/src/trpc.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { inferAsyncReturnType, initTRPC } from "@trpc/server" -import { drizzle } from "drizzle-orm/d1" -import { OAuth2Client } from "oslo/oauth2" -import superjson from "superjson" -import { Env } from "./env" - -export const createContext = (env: Env) => () => ({ - db: drizzle(env.DB), - oauth: new OAuth2Client( - env.AUTH_GOOGLE_CLIENT_ID, - "https://accounts.google.com/o/oauth2/auth", - "https://accounts.google.com/o/oauth2/token", - { - redirectURI: `${env.CLIENT_URL}`, - }, - ), - env, -}) - -type Context = inferAsyncReturnType> - -const t = initTRPC.context().create({ - transformer: superjson, -}) - -export const middleware = t.middleware -export const router = t.router - -export const logger = middleware(async opts => { - try { - // format date as HH:MM:SS - console.log(`Req ${new Date().toISOString()}: ${opts.path}`) - return await opts.next() - } catch (e) { - console.error(e) - throw e - } -}) - -export const publicProcedure = t.procedure.use(logger) diff --git a/packages/web/src/app/campaign/WorldSelection.tsx b/packages/web/src/app/campaign/WorldSelection.tsx index 603d88c6..2291d0eb 100644 --- a/packages/web/src/app/campaign/WorldSelection.tsx +++ b/packages/web/src/app/campaign/WorldSelection.tsx @@ -1,6 +1,5 @@ import { Suspense } from "react" import { WorldView } from "shared/src/views/world-view" -import { useAppStore } from "../../common/storage/app-store" import { trpc } from "../../common/trpc/trpc" import { World } from "./World" @@ -19,7 +18,7 @@ export function WorldSelection(props: { onSelected: (world: WorldView) => void } } export function WorldSelectionList(props: { onSelected: (world: WorldView) => void }) { - const userId = useAppStore(store => store.userId()) + const userId = "test" const [worldNames] = trpc.world.list.useSuspenseQuery() diff --git a/packages/web/src/app/layout/AuthButton.tsx b/packages/web/src/app/layout/AuthButton.tsx deleted file mode 100644 index 1e973b45..00000000 --- a/packages/web/src/app/layout/AuthButton.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Dialog } from "@headlessui/react" -import { useGoogleLogin } from "@react-oauth/google" -import { useRef, useState } from "react" -import { Modal } from "../../common/components/Modal" -import { trpcNative } from "../../common/trpc/trpc-native" - -type CreateAccountState = - | false - | { - tokenForCreation: string - } - -type LoggedInState = false | { username: string } - -export function AuthButton() { - const usernameRef = useRef(null) - - const [loading, setLoading] = useState(false) - const [loggedIn, setLoggedIn] = useState(false) - const [createAccount, setCreateAccount] = useState(false) - - const login = useGoogleLogin({ - onSuccess: ({ code }) => { - trpcNative.googleAuth.getToken - .query({ code }) - .then(response => { - if (response.type === "prompt-create") { - setCreateAccount({ tokenForCreation: response.tokenForCreation! }) - } else { - console.log("response: json", JSON.stringify(response)) - setLoggedIn({ username: response.username! }) - setLoading(false) - } - }) - .catch(() => { - setLoading(false) - }) - }, - onError: () => { - setLoading(false) - }, - onNonOAuthError: () => { - setLoading(false) - }, - flow: "auth-code", - }) - - function onLogin() { - setLoading(true) - login() - } - - function onCreate() { - if (!createAccount) { - return - } - - trpcNative.googleAuth.create - .query({ - tokenForCreation: createAccount.tokenForCreation, - username: usernameRef.current!.value, - }) - .then(() => { - setLoggedIn({ username: usernameRef.current!.value }) - setLoading(false) - }) - - setCreateAccount(false) - } - - return ( - <> - { - setCreateAccount(false) - setLoading(false) - }} - > -
- -
- We couldn't find an account with this email. Would you like to create - one? -
-
- -
- Create Account -
-
-
-
-
- {loggedIn && ( -
- Logged in as {loggedIn.username} -
- )} - {loading && ( -
-
-
- )} - {!loading && !loggedIn && ( -
- Login -
- )} - - ) -} diff --git a/packages/web/src/app/layout/Layout.tsx b/packages/web/src/app/layout/Layout.tsx index 93fce8ab..8e18230a 100644 --- a/packages/web/src/app/layout/Layout.tsx +++ b/packages/web/src/app/layout/Layout.tsx @@ -2,7 +2,7 @@ import { GoogleOAuthProvider } from "@react-oauth/google" import { Outlet } from "react-router-dom" import { useAppStore } from "../../common/storage/app-store" import { Alert } from "./Alert" -import { AuthButton } from "./AuthButton" +import { AuthButton } from "./auth-button/AuthButton" export function Layout() { const existsModal = useAppStore(state => state.modalCount > 0) diff --git a/packages/web/src/app/layout/auth-button/AuthButton.tsx b/packages/web/src/app/layout/auth-button/AuthButton.tsx new file mode 100644 index 00000000..de5fdf90 --- /dev/null +++ b/packages/web/src/app/layout/auth-button/AuthButton.tsx @@ -0,0 +1,76 @@ +import { useGoogleLogin } from "@react-oauth/google" +import { useState } from "react" +import { useAppStore } from "../../../common/storage/app-store" +import { trpcNative } from "../../../common/trpc/trpc-native" +import { CreateAccount } from "./CreateAccount" +import { LoginBadge } from "./LoginBadge" +import { RenameAccount } from "./RenameAccount" + +export function AuthButton() { + const [creationJwt, setCreationJwt] = useState(undefined) + const [loading, setLoading] = useState(false) + const [renaming, setRenaming] = useState(false) + + const updateJwt = useAppStore(x => x.updateJwt) + const updateUser = useAppStore(x => x.updateUser) + + const login = useGoogleLogin({ + onSuccess: async ({ code }) => { + try { + const response = await trpcNative.user.getToken.query({ code }) + + if (response.type === "prompt-create") { + console.log("prompt-create") + setCreationJwt(response.tokenForCreation) + } else { + updateJwt(response.token) + updateUser(await trpcNative.user.me.query()) + + setLoading(false) + } + } catch (error) { + console.error(error) + setLoading(false) + } + }, + onError: () => { + setLoading(false) + }, + onNonOAuthError: () => { + setLoading(false) + }, + flow: "auth-code", + }) + + async function onLogin() { + setLoading(true) + login() + } + + async function onRename() { + setRenaming(true) + setLoading(true) + } + + async function onRenameFinished() { + setRenaming(false) + setLoading(false) + } + + async function onCreateFinished() { + setCreationJwt(undefined) + setLoading(false) + } + + return ( + <> + + + + + ) +} diff --git a/packages/web/src/app/layout/auth-button/CreateAccount.tsx b/packages/web/src/app/layout/auth-button/CreateAccount.tsx new file mode 100644 index 00000000..33c1a232 --- /dev/null +++ b/packages/web/src/app/layout/auth-button/CreateAccount.tsx @@ -0,0 +1,93 @@ +import { Dialog } from "@headlessui/react" +import { useRef, useState } from "react" +import { Modal } from "../../../common/components/Modal" +import { useAppStore } from "../../../common/storage/app-store" +import { trpcNative } from "../../../common/trpc/trpc-native" + +export function CreateAccount(props: { + creationJwt: string | undefined + onCancel: () => void + onCreated: () => void +}) { + const usernameRef = useRef(null) + + const [loading, setLoading] = useState(false) + + const newAlert = useAppStore(x => x.newAlert) + const updateJwt = useAppStore(x => x.updateJwt) + const updateUser = useAppStore(x => x.updateUser) + + async function onCreate() { + try { + if (props.creationJwt === undefined) { + return + } + + setLoading(true) + + const result = await trpcNative.user.create.mutate({ + username: usernameRef.current!.value, + tokenForCreation: props.creationJwt, + }) + + setLoading(false) + + if (result.message === "username-taken") { + newAlert({ + message: "Username is already taken", + type: "warning", + }) + + return + } + + updateJwt(result.jwt) + updateUser(await trpcNative.user.me.query()) + + props.onCreated() + } catch (error) { + console.error(error) + props.onCancel() + } + } + + return ( + props.onCancel()}> +
+ +
+ We couldn't find an account with this email. Would you like to create one? +
+
+ + {loading === false && ( +
+ Create Account +
+ )} + {loading && ( +
+
+
+ )} +
+ +
+ + ) +} diff --git a/packages/web/src/app/layout/auth-button/LoginBadge.tsx b/packages/web/src/app/layout/auth-button/LoginBadge.tsx new file mode 100644 index 00000000..35ea2628 --- /dev/null +++ b/packages/web/src/app/layout/auth-button/LoginBadge.tsx @@ -0,0 +1,34 @@ +import { useAppStore } from "../../../common/storage/app-store" + +export function LoginBadge(props: { + loading: boolean + onClickLogin: () => void + onClickUser: () => void +}) { + const user = useAppStore(x => x.user) + + if (props.loading) { + return ( +
+
+
+ ) + } + + if (user) { + return ( +
+ Logged in as {user.username} +
+ ) + } + + return ( +
+ Login +
+ ) +} diff --git a/packages/web/src/app/layout/auth-button/RenameAccount.tsx b/packages/web/src/app/layout/auth-button/RenameAccount.tsx new file mode 100644 index 00000000..29816f0a --- /dev/null +++ b/packages/web/src/app/layout/auth-button/RenameAccount.tsx @@ -0,0 +1,84 @@ +import { Dialog } from "@headlessui/react" +import { useRef, useState } from "react" +import { Modal } from "../../../common/components/Modal" +import { useAppStore } from "../../../common/storage/app-store" +import { trpcNative } from "../../../common/trpc/trpc-native" + +export function RenameAccount(props: { open: boolean; onFinished: () => void }) { + const usernameRef = useRef(null) + + const [loading, setLoading] = useState(false) + + const newAlert = useAppStore(x => x.newAlert) + const updateUser = useAppStore(x => x.updateUser) + + async function onRename() { + try { + setLoading(true) + + const result = await trpcNative.user.rename.mutate({ + username: usernameRef.current!.value, + }) + + setLoading(false) + + if (result.message === "username-taken") { + newAlert({ + message: "Username is already taken", + type: "warning", + }) + + return + } + + updateUser(await trpcNative.user.me.query()) + + props.onFinished() + } catch (error) { + console.error(error) + props.onFinished() + } + } + + return ( + props.onFinished()}> +
+ +
+ {/* We couldn't find an account with this email. Would you like to create one? */} + {/* rename instead of create */} + Choose a new username +
+
+ + {loading === false && ( +
+ Create Account +
+ )} + {loading && ( +
+
+
+ )} +
+ +
+ + ) +} diff --git a/packages/web/src/common/runtime-framework/WithEntityStore.tsx b/packages/web/src/common/runtime-framework/WithEntityStore.tsx deleted file mode 100644 index a7eb56ce..00000000 --- a/packages/web/src/common/runtime-framework/WithEntityStore.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { EntityStore, EntityWith } from "runtime-framework" -import { useEntitySet } from "./use-entity-set" - -export function withEntityStore( - Component: (props: { entity: EntityWith }) => JSX.Element, - ...components: [...T] -) { - return function EntityComponent(props: { store: EntityStore }) { - const entities = useEntitySet(props.store, ...components) - - return ( - <> - {entities.map(entity => ( - - ))} - - ) - } -} diff --git a/packages/web/src/common/runtime-framework/use-entity-set.ts b/packages/web/src/common/runtime-framework/use-entity-set.ts deleted file mode 100644 index a9eb76e7..00000000 --- a/packages/web/src/common/runtime-framework/use-entity-set.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useSyncExternalStore } from "react" -import { EntityId, EntityStore, EntityWith } from "runtime-framework" - -export function useEntitySet( - store: EntityStore, - ...components: [...T] -): EntityWith[] { - const newSet = new Map>() - - for (const entity of store.find(...components)) { - newSet.set(entity.id, entity) - } - - let memoizeValues = [...newSet.values()] - - return useSyncExternalStore( - callback => - store.listenToNew( - (entity, isNew) => { - if (isNew) { - newSet.set(entity.id, entity) - memoizeValues = [...newSet.values()] - } - - callback() - }, - entity => { - newSet.delete(entity.id) - - memoizeValues = [...newSet.values()] - - callback() - }, - ...components, - ), - () => memoizeValues, - ) -} diff --git a/packages/web/src/common/runtime-framework/use-entity-tracker.ts b/packages/web/src/common/runtime-framework/use-entity-tracker.ts deleted file mode 100644 index 9bc0c682..00000000 --- a/packages/web/src/common/runtime-framework/use-entity-tracker.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useSyncExternalStore } from "react" -import { Entity } from "runtime-framework" -import { EntityTracker } from "runtime-framework/src/entity-tracker" - -export function useEntityTracker(tracker: EntityTracker) { - let memoizeValues: [Entity[], (keyof Components)[]] = [ - [...tracker], - tracker.components(), - ] - - return useSyncExternalStore( - callback => { - const onChange = () => { - memoizeValues = [[...tracker], tracker.components()] - callback() - } - - tracker.onChange(onChange) - - return () => tracker.onChange(onChange) - }, - () => memoizeValues, - ) -} diff --git a/packages/web/src/common/runtime-framework/use-message.ts b/packages/web/src/common/runtime-framework/use-message.ts deleted file mode 100644 index 8cad9aa9..00000000 --- a/packages/web/src/common/runtime-framework/use-message.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect, useRef } from "react" -import { MessageStore } from "runtime-framework" - -export function useMessage< - Components extends object, - Messages extends object, - T extends keyof Messages, ->(store: MessageStore, message: T, listener: (message: Messages[T]) => void) { - const listenerRef = useRef(listener) - - useEffect(() => void (listenerRef.current = listener), [listener]) - useEffect(() => store.listenTo(message, listenerRef.current), [store, message]) -} diff --git a/packages/web/src/common/storage/app-store.ts b/packages/web/src/common/storage/app-store.ts index 8517a0fe..27f7346a 100644 --- a/packages/web/src/common/storage/app-store.ts +++ b/packages/web/src/common/storage/app-store.ts @@ -1,15 +1,14 @@ -import { nanoid } from "nanoid" -import { nameFromString } from "shared/src/names" import { create } from "zustand" import { StateStorage, createJSONStorage, persist } from "zustand/middleware" import { AlertProps } from "../../app/layout/Alert" import { db } from "./db" export interface AppStore { - token: string + jwt: string | undefined + user: { username: string } | undefined - userId(): string - userName(): string + updateJwt: (jwt: string) => void + updateUser: (user: { username: string }) => void alerts: AlertProps[] modalCount: number @@ -29,14 +28,19 @@ const storage: StateStorage = { }, } -import jsSHA from "jssha" - export const useAppStore = create()( persist( - (set, get) => ({ - token: nanoid(), - userId: () => new jsSHA("SHA-512", "TEXT").update(get().token ?? "").getHash("B64"), - userName: () => nameFromString(get().userId()), + set => ({ + jwt: undefined, + user: undefined, + + updateJwt: jwt => { + set({ jwt }) + }, + + updateUser: user => { + set({ user }) + }, alerts: [], modalCount: 0, @@ -58,14 +62,9 @@ export const useAppStore = create()( storage: createJSONStorage(() => storage), partialize: state => ({ - token: state.token, + jwt: state.jwt, }), - onRehydrateStorage: () => state => { - console.log( - "User Id: " + state?.userId(), - ", User Name: " + nameFromString(state?.userId() ?? ""), - ) - }, + onRehydrateStorage: () => () => {}, }, ), ) diff --git a/packages/web/src/common/trpc/trpc-native.ts b/packages/web/src/common/trpc/trpc-native.ts index 73c229fc..5c2bbbd9 100644 --- a/packages/web/src/common/trpc/trpc-native.ts +++ b/packages/web/src/common/trpc/trpc-native.ts @@ -1,6 +1,7 @@ import { createTRPCProxyClient, httpBatchLink } from "@trpc/client" import superjson from "superjson" -import { AppRouter } from "../../../../server/src/trpc-router" +import { AppRouter } from "../../../../server/src/framework/trpc-router" +import { useAppStore } from "../storage/app-store" export const options = { links: [ @@ -13,6 +14,17 @@ export const options = { credentials: "include", }) }, + headers() { + const jwt = useAppStore.getState().jwt + + if (jwt) { + return { + authorization: jwt, + } + } + + return {} + }, }), ], transformer: superjson, diff --git a/packages/web/src/common/trpc/trpc.ts b/packages/web/src/common/trpc/trpc.ts index 559e9309..415e5a85 100644 --- a/packages/web/src/common/trpc/trpc.ts +++ b/packages/web/src/common/trpc/trpc.ts @@ -1,4 +1,4 @@ import { createTRPCReact } from "@trpc/react-query" -import type { AppRouter } from "../../../../server/src/trpc-router" +import { AppRouter } from "../../../../server/src/framework/trpc-router" export const trpc = createTRPCReact()