diff --git a/src/client/hooks/useAuth.ts b/src/client/hooks/useAuth.ts new file mode 100644 index 0000000..88e6e40 --- /dev/null +++ b/src/client/hooks/useAuth.ts @@ -0,0 +1,97 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api"; + +interface AuthState { + user: { id: number } | null; + setupRequired: boolean; +} + +export function useAuth() { + return useQuery({ + queryKey: ["auth"], + queryFn: () => apiGet("/api/auth/me"), + staleTime: 5 * 60 * 1000, + }); +} + +export function useLogin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { username: string; password: string }) => + apiPost<{ username: string }>("/api/auth/login", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + }); +} + +export function useLogout() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => apiPost<{ success: boolean }>("/api/auth/logout", {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + }); +} + +export function useSetup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { username: string; password: string }) => + apiPost<{ username: string }>("/api/auth/setup", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + }); +} + +export function useChangePassword() { + return useMutation({ + mutationFn: (data: { currentPassword: string; newPassword: string }) => + apiPut<{ success: boolean }>("/api/auth/password", data), + }); +} + +interface ApiKeyListItem { + id: number; + name: string; + keyPrefix: string; + createdAt: string; +} + +interface ApiKeyResponse { + id: number; + name: string; + key: string; + prefix: string; +} + +export function useApiKeys() { + return useQuery({ + queryKey: ["apiKeys"], + queryFn: () => apiGet("/api/auth/keys"), + }); +} + +export function useCreateApiKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string }) => + apiPost("/api/auth/keys", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }); + }, + }); +} + +export function useDeleteApiKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiDelete<{ success: boolean }>(`/api/auth/keys/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }); + }, + }); +} diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index c274bdd..1f7b3f4 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as CollectionIndexRouteImport } from './routes/collection/index' import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId' @@ -20,6 +21,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -43,6 +49,7 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/login': typeof LoginRoute '/settings': typeof SettingsRoute '/setups/$setupId': typeof SetupsSetupIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute @@ -50,6 +57,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/login': typeof LoginRoute '/settings': typeof SettingsRoute '/setups/$setupId': typeof SetupsSetupIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute @@ -58,6 +66,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/login': typeof LoginRoute '/settings': typeof SettingsRoute '/setups/$setupId': typeof SetupsSetupIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute @@ -67,6 +76,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/login' | '/settings' | '/setups/$setupId' | '/threads/$threadId' @@ -74,6 +84,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/login' | '/settings' | '/setups/$setupId' | '/threads/$threadId' @@ -81,6 +92,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/login' | '/settings' | '/setups/$setupId' | '/threads/$threadId' @@ -89,6 +101,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + LoginRoute: typeof LoginRoute SettingsRoute: typeof SettingsRoute SetupsSetupIdRoute: typeof SetupsSetupIdRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute @@ -104,6 +117,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -137,6 +157,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + LoginRoute: LoginRoute, SettingsRoute: SettingsRoute, SetupsSetupIdRoute: SetupsSetupIdRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute, diff --git a/src/client/routes/login.tsx b/src/client/routes/login.tsx new file mode 100644 index 0000000..888f3ca --- /dev/null +++ b/src/client/routes/login.tsx @@ -0,0 +1,104 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useAuth, useLogin, useSetup } from "../hooks/useAuth"; + +export const Route = createFileRoute("/login")({ + component: LoginPage, +}); + +function LoginPage() { + const navigate = useNavigate(); + const { data: auth } = useAuth(); + const login = useLogin(); + const setup = useSetup(); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const isSetup = auth?.setupRequired ?? false; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + try { + if (isSetup) { + await setup.mutateAsync({ username, password }); + } else { + await login.mutateAsync({ username, password }); + } + navigate({ to: "/" }); + } catch (err) { + setError((err as Error).message); + } + } + + const isPending = login.isPending || setup.isPending; + + return ( +
+
+

+ {isSetup ? "Create Account" : "Sign In"} +

+ +
+ {isSetup && ( +

+ Create your admin account to manage your gear collection. +

+ )} + +
+ + setUsername(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={isSetup ? 6 : undefined} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-200" + /> +
+ + {error &&

{error}

} + + +
+
+
+ ); +}