add web ui and user route

This commit is contained in:
2026-02-15 19:29:09 +03:00
parent 8d9b5c32c6
commit 7ced62517a
24 changed files with 3025 additions and 0 deletions

3
Justfile Normal file
View File

@@ -0,0 +1,3 @@
dev:
cd web && pnpm run build
go run .

View File

@@ -1,6 +1,7 @@
package auth
import (
"fmt"
"os"
"time"
@@ -28,3 +29,20 @@ func GenerateUserToken(userId int64, expiryTime time.Time) (string, error) {
return token.SignedString([]byte(secretKey))
}
func ValidateUserToken(token string) (int64, error) {
claims := &UserClaims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
return []byte(secretKey), nil
})
if err != nil {
return -1, fmt.Errorf("failed to parse token: %v", err)
}
if !parsed.Valid {
return -1, fmt.Errorf("invalid token")
}
return claims.UserID, nil
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get authenticated user
type: http
seq: 1
}
get {
url: {{base_url}}/api/user
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

8
docs/api/user/folder.bru Normal file
View File

@@ -0,0 +1,8 @@
meta {
name: user
seq: 2
}
auth {
mode: inherit
}

37
main.go
View File

@@ -1,6 +1,7 @@
package main
import (
"embed"
"encoding/json"
"fmt"
"game-wishlist/auth"
@@ -15,6 +16,11 @@ import (
"gorm.io/gorm"
)
var (
//go:embed web/dist
webOutput embed.FS
)
func sendError(w http.ResponseWriter, msg string, err error, status int) {
m := msg
if err != nil {
@@ -48,6 +54,15 @@ func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" {
path = "/index.html"
}
filePath := "web/dist" + path
http.ServeFileFS(w, r, webOutput, filePath)
})
mux.HandleFunc("POST /api/auth/register", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Login string `json:"login"`
@@ -136,6 +151,28 @@ func main() {
}{true}, 200)
})
mux.HandleFunc("GET /api/user", func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("token")
if err != nil {
sendError(w, "no token cookie", err, 401)
return
}
userId, err := auth.ValidateUserToken(c.Value)
if err != nil {
sendError(w, "invalid token", err, 401)
return
}
user := &model.User{}
if tx := db.First(user, "id = ?", userId); tx.Error != nil {
sendError(w, "user not found", err, 404)
return
}
sendJSON(w, user, 200)
})
log.Print("starting http server on http://localhost:5000")
if err := http.ListenAndServe(":5000", mux); err != nil {
log.Fatalf("failed to start http server: %v\n", err)

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
web/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

2421
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

39
web/src/App.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { AuthModal } from "./components/AuthModal";
import { useUserQuery } from "./api/user";
import { Header } from "./components/Header";
export default function App() {
const { data: user, isFetched } = useUserQuery();
const [showAuthModal, setShowAuthModal] = useState(false);
useEffect(() => {
if (isFetched && !user) {
// eslint-disable-next-line
setShowAuthModal(true);
}
}, [user, isFetched]);
if (!isFetched) {
return (
<div className="max-w-360 w-full mx-auto p-2">
<span>loading...</span>
</div>
);
}
return (
<div className="max-w-360 w-full mx-auto p-2">
{showAuthModal && (
<AuthModal
allowedToClose={isFetched && !!user}
open={showAuthModal}
onClose={() => setShowAuthModal(false)}
/>
)}
<Header />
</div>
);
}

46
web/src/api/auth.ts Normal file
View File

@@ -0,0 +1,46 @@
import { useMutation } from "@tanstack/react-query";
export type LoginData = {
login: string;
password: string;
};
export const useLoginMutation = () => {
return useMutation({
mutationFn: async (data: LoginData) => {
const resp = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify(data),
});
return await resp.json();
},
});
};
export type RegisterData = {
login: string;
password: string;
};
export const useRegisterMutation = () => {
return useMutation({
mutationFn: async (data: RegisterData) => {
const resp = await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(data),
});
return await resp.json();
},
});
};
export const useLogoutMutation = () => {
return useMutation({
mutationFn: async () => {
const resp = await fetch("/api/auth/logout", {
method: "POST",
});
return await resp.json();
},
});
};

19
web/src/api/user.ts Normal file
View File

@@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
export type User = {
id: number;
login: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};
export const useUserQuery = () => {
return useQuery({
queryKey: ["user"],
queryFn: async () => {
const resp = await fetch("/api/user");
return (await resp.json()) as User;
},
});
};

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { Modal } from "./ui/Modal";
import { LoginForm } from "./LoginForm";
import { RegisterForm } from "./RegisterForm";
export type AuthModalProps = {
allowedToClose?: boolean;
open: boolean;
onClose: () => void;
};
export const AuthModal = ({
allowedToClose = true,
open,
onClose,
}: AuthModalProps) => {
const [isLogin, setIsLogin] = useState(true);
return (
<Modal
size={400}
allowedToClose={allowedToClose}
open={open}
onClose={onClose}
>
<h2 className="text-lg text-center font-medium">
{isLogin ? "Login" : "Register"}
</h2>
{isLogin ? (
<LoginForm onClose={onClose} onRegister={() => setIsLogin(false)} />
) : (
<RegisterForm onClose={onClose} onLogin={() => setIsLogin(true)} />
)}
</Modal>
);
};

View File

@@ -0,0 +1,31 @@
import { useQueryClient } from "@tanstack/react-query";
import { useLogoutMutation } from "../api/auth";
import { useUserQuery } from "../api/user";
export const Header = () => {
const queryClient = useQueryClient();
const { data: user } = useUserQuery();
const logoutMutation = useLogoutMutation();
const handleLogout = () => {
logoutMutation.mutate(undefined, {
onSuccess() {
queryClient.clear();
location.href = "/";
},
});
};
return (
<header className="flex justify-between items-center">
<h1 className="text-2xl font-semibold">games-wishlist</h1>
{user && (
<button type="button" className="cursor-pointer" onClick={handleLogout}>
Log out
</button>
)}
</header>
);
};

View File

@@ -0,0 +1,60 @@
import { useForm } from "react-hook-form";
import { type LoginData, useLoginMutation } from "../api/auth";
import { useQueryClient } from "@tanstack/react-query";
export type LoginFormProps = {
onClose: () => void;
onRegister: () => void;
};
export const LoginForm = ({ onClose, onRegister }: LoginFormProps) => {
const queryClient = useQueryClient();
const mutation = useLoginMutation();
const form = useForm<LoginData>({
defaultValues: {
login: "",
password: "",
},
});
const onSubmit = form.handleSubmit((data) => {
mutation.mutate(data, {
onSuccess() {
form.reset();
onClose();
queryClient.invalidateQueries({ queryKey: ["user"] });
},
});
});
return (
<form className="flex flex-col gap-2 mt-5" onSubmit={onSubmit}>
<input
type="text"
placeholder="login"
className="w-full"
{...form.register("login", {
required: true,
})}
/>
<input
type="password"
placeholder="password"
className="w-full"
{...form.register("password", {
required: true,
})}
/>
<button type="submit" className="w-full mt-2">
Continue
</button>
<p>
Don't have an account?{" "}
<button type="button" onClick={() => onRegister()}>
Register
</button>
</p>
</form>
);
};

View File

@@ -0,0 +1,59 @@
import { useForm } from "react-hook-form";
import { type RegisterData, useRegisterMutation } from "../api/auth";
import { useQueryClient } from "@tanstack/react-query";
export type RegisterFormProps = {
onClose: () => void;
onLogin: () => void;
};
export const RegisterForm = ({ onClose, onLogin }: RegisterFormProps) => {
const queryClient = useQueryClient();
const mutation = useRegisterMutation();
const form = useForm<RegisterData>({
defaultValues: {
login: "",
password: "",
},
});
const onSubmit = form.handleSubmit((data) => {
mutation.mutate(data, {
onSuccess() {
onClose();
queryClient.invalidateQueries({ queryKey: ["user"] });
},
});
});
return (
<form className="flex flex-col gap-2 mt-5" onSubmit={onSubmit}>
<input
type="text"
placeholder="login"
className="w-full"
{...form.register("login", {
required: true,
})}
/>
<input
type="password"
placeholder="password"
className="w-full"
{...form.register("password", {
required: true,
})}
/>
<button type="submit" className="w-full mt-2">
Continue
</button>
<p>
Already have an account?{" "}
<button type="button" onClick={() => onLogin()}>
Login
</button>
</p>
</form>
);
};

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
export type ModalProps = {
children: ReactNode;
open: boolean;
allowedToClose?: boolean;
size?: number | string;
onClose: () => void;
};
export const Modal = ({
children,
open,
allowedToClose = true,
size = 500,
onClose,
}: ModalProps) => {
if (!open) {
return null;
}
return createPortal(
<div
className="fixed inset-0 z-30 bg-neutral-900/60 backdrop-blur-lg flex items-center justify-center"
onPointerDown={() => allowedToClose && onClose()}
>
<div
className="bg-white rounded-md m-2 p-2 w-full"
style={{ maxWidth: size }}
onPointerDown={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body,
);
};

1
web/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

21
web/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react({
babel: {
plugins: [["babel-plugin-react-compiler"]],
},
}),
],
});