Improve frontend auth handling
This commit is contained in:
@@ -267,3 +267,23 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"quay/internal/security"
|
"quay/internal/security"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequireAuth() fiber.Handler {
|
func RequireAuth() fiber.Handler {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, d
|
|||||||
protected.Get("/users", userHandler.GetAllUsers)
|
protected.Get("/users", userHandler.GetAllUsers)
|
||||||
protected.Get("/users/:id", userHandler.GetUserById)
|
protected.Get("/users/:id", userHandler.GetUserById)
|
||||||
protected.Get("/users/by-name/:name", userHandler.GetUserByName)
|
protected.Get("/users/by-name/:name", userHandler.GetUserByName)
|
||||||
|
protected.Get("/me", userHandler.GetMe)
|
||||||
|
|
||||||
// Allow creating the very first admin user without auth (bootstrap).
|
// Allow creating the very first admin user without auth (bootstrap).
|
||||||
// If an admin already exists, require auth to create users.
|
// If an admin already exists, require auth to create users.
|
||||||
|
|||||||
@@ -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> => {
|
export const login = async (name: string, password: string): Promise<string> => {
|
||||||
const response = await fetch(makeApiUrl('/login'), {
|
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();
|
const data = await response.json();
|
||||||
return data.token;
|
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();
|
||||||
|
};
|
||||||
|
|||||||
@@ -34,13 +34,7 @@ const Navbar = ({ userName, profilePictureUrl }: NavbarProps) => {
|
|||||||
alt={userName + ' profile image'}
|
alt={userName + ' profile image'}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{userName
|
{userName ? userName.charAt(0).toUpperCase() : 'A'}
|
||||||
? userName
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
: 'A'}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -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
@@ -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 { createRoot } from 'react-dom/client';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import MainPage from './pages/Main/Main';
|
import MainPage from './pages/Main/Main';
|
||||||
@@ -9,6 +9,7 @@ import NewSite from './pages/NewSite/NewSite';
|
|||||||
import SiteOverview from './pages/SiteOverview/SiteOverview';
|
import SiteOverview from './pages/SiteOverview/SiteOverview';
|
||||||
import Users from './pages/Users/Users';
|
import Users from './pages/Users/Users';
|
||||||
import Login from './pages/Login/Login';
|
import Login from './pages/Login/Login';
|
||||||
|
import Logout from './pages/Logout/Logout';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -18,34 +19,11 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route
|
<Route path="/" element={<MainPage />} />
|
||||||
path="/"
|
<Route path="/sites/new" element={<NewSite />} />
|
||||||
element={
|
<Route path="/sites/:id" element={<SiteOverview />} />
|
||||||
!localStorage.getItem('token') ? <Navigate to="/login" /> : <MainPage />
|
<Route path="/users" element={<Users />} />
|
||||||
}
|
<Route path="/logout" element={<Logout />} />
|
||||||
/>
|
|
||||||
<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 />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { login } from '../../api/auth.api';
|
import { login } from '../../api/auth.api';
|
||||||
import { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { setToken } from '../../utils/credentials';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -15,9 +16,9 @@ const Login = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const token = await login(name, password);
|
const token = await login(name, password);
|
||||||
localStorage.setItem('token', token);
|
setToken(token);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError('Invalid credentials');
|
setError('Invalid credentials');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,22 +1,43 @@
|
|||||||
import { useEffect, type PropsWithChildren } from 'react';
|
import { useEffect, type PropsWithChildren } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import Navbar from '../components/navbar';
|
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 {
|
interface PageProps {
|
||||||
title: string;
|
title: string;
|
||||||
className?: 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(() => {
|
useEffect(() => {
|
||||||
if (title) document.title = title;
|
if (title) document.title = 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Navbar
|
<Navbar profilePictureUrl={''} userName={userName} />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<main className={`max-w-(--breakpoint-xl) mx-auto px-4 ${className || ''}`}>
|
<main className={`max-w-(--breakpoint-xl) mx-auto px-4 ${className || ''}`}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user