feat: add useAuth hook and login page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
97
src/client/hooks/useAuth.ts
Normal file
97
src/client/hooks/useAuth.ts
Normal file
@@ -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<AuthState>("/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<ApiKeyListItem[]>("/api/auth/keys"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateApiKey() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { name: string }) =>
|
||||||
|
apiPost<ApiKeyResponse>("/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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
import { Route as CollectionIndexRouteImport } from './routes/collection/index'
|
||||||
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
|
||||||
@@ -20,6 +21,11 @@ const SettingsRoute = SettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -43,6 +49,7 @@ const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
@@ -50,6 +57,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
@@ -58,6 +66,7 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/setups/$setupId': typeof SetupsSetupIdRoute
|
'/setups/$setupId': typeof SetupsSetupIdRoute
|
||||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||||
@@ -67,6 +76,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/login'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
@@ -74,6 +84,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/login'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
@@ -81,6 +92,7 @@ export interface FileRouteTypes {
|
|||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/login'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/setups/$setupId'
|
| '/setups/$setupId'
|
||||||
| '/threads/$threadId'
|
| '/threads/$threadId'
|
||||||
@@ -89,6 +101,7 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
SetupsSetupIdRoute: typeof SetupsSetupIdRoute
|
||||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||||
@@ -104,6 +117,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SettingsRouteImport
|
preLoaderRoute: typeof SettingsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -137,6 +157,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
SetupsSetupIdRoute: SetupsSetupIdRoute,
|
||||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||||
|
|||||||
104
src/client/routes/login.tsx
Normal file
104
src/client/routes/login.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900 text-center mb-6">
|
||||||
|
{isSetup ? "Create Account" : "Sign In"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-white rounded-xl border border-gray-100 p-6 space-y-4"
|
||||||
|
>
|
||||||
|
{isSetup && (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Create your admin account to manage your gear collection.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full py-2 px-4 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? "..." : isSetup ? "Create Account" : "Sign In"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user