Improve frontend auth handling

This commit is contained in:
2026-05-02 15:13:28 +02:00
parent f1fd72520a
commit 36a5911fe4
11 changed files with 126 additions and 47 deletions
+20
View File
@@ -267,3 +267,23 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
// GetMe returns the currently authenticated user's details
func (h *UserHandler) GetMe(c fiber.Ctx) error {
uid, ok := c.Locals("user_id").(string)
if !ok || uid == "" {
return c.Status(fiber.StatusUnauthorized).JSON(&models.APIError{Message: "Unauthorized"})
}
user, err := h.Repo.GetUserById(uid)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "User not found"})
}
log.Println("Error getting user: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while getting user"})
}
user.HashedPassword = ""
return c.JSON(user)
}
+3 -3
View File
@@ -1,11 +1,11 @@
package middleware
import (
"strings"
"strings"
"quay/internal/security"
"quay/internal/security"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3"
)
func RequireAuth() fiber.Handler {
+1
View File
@@ -75,6 +75,7 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
protected.Get("/users", userHandler.GetAllUsers)
protected.Get("/users/:id", userHandler.GetUserById)
protected.Get("/users/by-name/:name", userHandler.GetUserByName)
protected.Get("/me", userHandler.GetMe)
// Allow creating the very first admin user without auth (bootstrap).
// If an admin already exists, require auth to create users.
+10 -1
View File
@@ -1,4 +1,5 @@
import { makeApiUrl } from '.';
import { makeApiUrl, fetchWithAuth } from '.';
import type { User } from './types/user';
export const login = async (name: string, password: string): Promise<string> => {
const response = await fetch(makeApiUrl('/login'), {
@@ -16,3 +17,11 @@ export const login = async (name: string, password: string): Promise<string> =>
const data = await response.json();
return data.token;
};
export const getMe = async (): Promise<User> => {
const response = await fetchWithAuth('/me', { method: 'GET' });
if (!response.ok) {
throw new Error('Failed to fetch current user');
}
return response.json();
};
+1 -7
View File
@@ -34,13 +34,7 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => {
alt={userName + ' profile image'}
/>
<AvatarFallback className="rounded-lg">
{userName
? userName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
: 'A'}
{userName ? userName.charAt(0).toUpperCase() : 'A'}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { getMe } from '../../api/auth.api';
export function useCurrentUser() {
return useQuery({
queryKey: ['me'],
queryFn: getMe,
retry: false,
refetchOnWindowFocus: false,
});
}
+7 -29
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Route, Routes, Navigate } from 'react-router';
import { BrowserRouter, Route, Routes } from 'react-router';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MainPage from './pages/Main/Main';
@@ -9,6 +9,7 @@ import NewSite from './pages/NewSite/NewSite';
import SiteOverview from './pages/SiteOverview/SiteOverview';
import Users from './pages/Users/Users';
import Login from './pages/Login/Login';
import Logout from './pages/Logout/Logout';
const queryClient = new QueryClient();
@@ -18,34 +19,11 @@ createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
!localStorage.getItem('token') ? <Navigate to="/login" /> : <MainPage />
}
/>
<Route
path="/sites/new"
element={
!localStorage.getItem('token') ? <Navigate to="/login" /> : <NewSite />
}
/>
<Route
path="/sites/:id"
element={
!localStorage.getItem('token') ? (
<Navigate to="/login" />
) : (
<SiteOverview />
)
}
/>
<Route
path="/users"
element={
!localStorage.getItem('token') ? <Navigate to="/login" /> : <Users />
}
/>
<Route path="/" element={<MainPage />} />
<Route path="/sites/new" element={<NewSite />} />
<Route path="/sites/:id" element={<SiteOverview />} />
<Route path="/users" element={<Users />} />
<Route path="/logout" element={<Logout />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
+3 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { login } from '../../api/auth.api';
import { Input } from '../../components/ui/input';
import { Button } from '../../components/ui/button';
import { setToken } from '../../utils/credentials';
const Login = () => {
const [name, setName] = useState('');
@@ -15,9 +16,9 @@ const Login = () => {
setError(null);
try {
const token = await login(name, password);
localStorage.setItem('token', token);
setToken(token);
navigate('/');
} catch (err) {
} catch {
setError('Invalid credentials');
}
};
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import { removeToken } from '../../utils/credentials';
const Logout = () => {
const navigate = useNavigate();
const qc = useQueryClient();
useEffect(() => {
removeToken();
qc.removeQueries({ queryKey: ['me'] });
navigate('/login', { replace: true });
}, [navigate, qc]);
return null;
};
export default Logout;
+26 -5
View File
@@ -1,22 +1,43 @@
import { useEffect, type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router';
import Navbar from '../components/navbar';
import { useCurrentUser } from '../hooks/api/useCurrentUser';
import type { User } from '../api/types/user';
import { getToken } from '../utils/credentials';
interface PageProps {
title: string;
className?: string;
requireAuth?: boolean;
}
const Page = ({ children, title, className }: PropsWithChildren<PageProps>) => {
const Page = ({ children, title, className, requireAuth = true }: PropsWithChildren<PageProps>) => {
const navigate = useNavigate();
const { data: user, isLoading, isError } = useCurrentUser();
useEffect(() => {
if (title) document.title = title;
}, [title]);
useEffect(() => {
if (!requireAuth) return;
const token = getToken();
if (!token) {
navigate('/login', { replace: true });
return;
}
if (!isLoading && isError) {
navigate('/login', { replace: true });
}
}, [requireAuth, isLoading, isError, navigate]);
const userName = (user as User | undefined)?.name;
return (
<div>
<Navbar
profilePictureUrl="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.pinimg.com%2Foriginals%2Fca%2Fa9%2F89%2Fcaa98995f578f038373cb6874b68fdfb.jpg&f=1&nofb=1&ipt=0e7adaafec6ff834508c5ea329c5686552e35fe6bc06c299f38ebee88b22cf0a"
userName="jan"
/>
<Navbar profilePictureUrl={''} userName={userName} />
<main className={`max-w-(--breakpoint-xl) mx-auto px-4 ${className || ''}`}>
{children}
</main>
+25
View File
@@ -0,0 +1,25 @@
const TOKEN_STORAGE_KEY = 'token';
export const getToken = (): string | null => {
try {
return localStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
};
export const setToken = (token: string) => {
try {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
} catch {
// ingore
}
};
export const removeToken = () => {
try {
localStorage.removeItem(TOKEN_STORAGE_KEY);
} catch {
// ingore
}
};