Login/logout
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
// Import Routes
|
// Import Routes
|
||||||
|
|
||||||
import { Route as rootRoute } from './routes/__root'
|
import { Route as rootRoute } from './routes/__root'
|
||||||
|
import { Route as LoginImport } from './routes/login'
|
||||||
import { Route as AboutImport } from './routes/about'
|
import { Route as AboutImport } from './routes/about'
|
||||||
import { Route as IndexImport } from './routes/index'
|
import { Route as IndexImport } from './routes/index'
|
||||||
import { Route as SessionsIndexImport } from './routes/sessions.index'
|
import { Route as SessionsIndexImport } from './routes/sessions.index'
|
||||||
@@ -19,6 +20,12 @@ import { Route as CampaignsCampaignIdImport } from './routes/campaigns.$campaign
|
|||||||
|
|
||||||
// Create/Update Routes
|
// Create/Update Routes
|
||||||
|
|
||||||
|
const LoginRoute = LoginImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const AboutRoute = AboutImport.update({
|
const AboutRoute = AboutImport.update({
|
||||||
id: '/about',
|
id: '/about',
|
||||||
path: '/about',
|
path: '/about',
|
||||||
@@ -67,6 +74,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AboutImport
|
preLoaderRoute: typeof AboutImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
'/campaigns/$campaignId': {
|
'/campaigns/$campaignId': {
|
||||||
id: '/campaigns/$campaignId'
|
id: '/campaigns/$campaignId'
|
||||||
path: '/campaigns/$campaignId'
|
path: '/campaigns/$campaignId'
|
||||||
@@ -96,6 +110,7 @@ declare module '@tanstack/react-router' {
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
|
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
|
||||||
'/campaigns': typeof CampaignsIndexRoute
|
'/campaigns': typeof CampaignsIndexRoute
|
||||||
'/sessions': typeof SessionsIndexRoute
|
'/sessions': typeof SessionsIndexRoute
|
||||||
@@ -104,6 +119,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
|
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
|
||||||
'/campaigns': typeof CampaignsIndexRoute
|
'/campaigns': typeof CampaignsIndexRoute
|
||||||
'/sessions': typeof SessionsIndexRoute
|
'/sessions': typeof SessionsIndexRoute
|
||||||
@@ -113,6 +129,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRoute
|
__root__: typeof rootRoute
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
|
'/campaigns/$campaignId': typeof CampaignsCampaignIdRoute
|
||||||
'/campaigns/': typeof CampaignsIndexRoute
|
'/campaigns/': typeof CampaignsIndexRoute
|
||||||
'/sessions/': typeof SessionsIndexRoute
|
'/sessions/': typeof SessionsIndexRoute
|
||||||
@@ -123,15 +140,23 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
|
| '/login'
|
||||||
| '/campaigns/$campaignId'
|
| '/campaigns/$campaignId'
|
||||||
| '/campaigns'
|
| '/campaigns'
|
||||||
| '/sessions'
|
| '/sessions'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/about' | '/campaigns/$campaignId' | '/campaigns' | '/sessions'
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/about'
|
||||||
|
| '/login'
|
||||||
|
| '/campaigns/$campaignId'
|
||||||
|
| '/campaigns'
|
||||||
|
| '/sessions'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
|
| '/login'
|
||||||
| '/campaigns/$campaignId'
|
| '/campaigns/$campaignId'
|
||||||
| '/campaigns/'
|
| '/campaigns/'
|
||||||
| '/sessions/'
|
| '/sessions/'
|
||||||
@@ -141,6 +166,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AboutRoute: typeof AboutRoute
|
AboutRoute: typeof AboutRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
CampaignsCampaignIdRoute: typeof CampaignsCampaignIdRoute
|
CampaignsCampaignIdRoute: typeof CampaignsCampaignIdRoute
|
||||||
CampaignsIndexRoute: typeof CampaignsIndexRoute
|
CampaignsIndexRoute: typeof CampaignsIndexRoute
|
||||||
SessionsIndexRoute: typeof SessionsIndexRoute
|
SessionsIndexRoute: typeof SessionsIndexRoute
|
||||||
@@ -149,6 +175,7 @@ export interface RootRouteChildren {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AboutRoute: AboutRoute,
|
AboutRoute: AboutRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
CampaignsCampaignIdRoute: CampaignsCampaignIdRoute,
|
CampaignsCampaignIdRoute: CampaignsCampaignIdRoute,
|
||||||
CampaignsIndexRoute: CampaignsIndexRoute,
|
CampaignsIndexRoute: CampaignsIndexRoute,
|
||||||
SessionsIndexRoute: SessionsIndexRoute,
|
SessionsIndexRoute: SessionsIndexRoute,
|
||||||
@@ -166,6 +193,7 @@ export const routeTree = rootRoute
|
|||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
"/about",
|
"/about",
|
||||||
|
"/login",
|
||||||
"/campaigns/$campaignId",
|
"/campaigns/$campaignId",
|
||||||
"/campaigns/",
|
"/campaigns/",
|
||||||
"/sessions/"
|
"/sessions/"
|
||||||
@@ -177,6 +205,9 @@ export const routeTree = rootRoute
|
|||||||
"/about": {
|
"/about": {
|
||||||
"filePath": "about.tsx"
|
"filePath": "about.tsx"
|
||||||
},
|
},
|
||||||
|
"/login": {
|
||||||
|
"filePath": "login.tsx"
|
||||||
|
},
|
||||||
"/campaigns/$campaignId": {
|
"/campaigns/$campaignId": {
|
||||||
"filePath": "campaigns.$campaignId.tsx"
|
"filePath": "campaigns.$campaignId.tsx"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import { AuthProvider } from "@/context/auth/AuthContext";
|
import { AuthProvider, useAuth } from "@/context/auth/AuthContext";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
/** Root of the application */
|
/**
|
||||||
export const Route = createRootRoute({
|
* Root header with navigation and user authentication controls.
|
||||||
component: () => (
|
*/
|
||||||
<QueryClientProvider client={queryClient}>
|
function RootHeader() {
|
||||||
<AuthProvider>
|
const { user, logout, isLoading } = useAuth();
|
||||||
|
return (
|
||||||
<header className="flex items-center justify-between px-8 py-4 border-b border-slate-700 bg-slate-900">
|
<header className="flex items-center justify-between px-8 py-4 border-b border-slate-700 bg-slate-900">
|
||||||
<h1 className="text-2xl font-bold text-slate-100 m-0">
|
<h1 className="text-2xl font-bold text-slate-100 m-0">
|
||||||
DM's Table Companion
|
DM's Table Companion
|
||||||
@@ -46,7 +47,41 @@ export const Route = createRootRoute({
|
|||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-200 text-sm" aria-label="User email">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white text-sm font-semibold px-3 py-1 rounded transition-colors disabled:opacity-60"
|
||||||
|
aria-label="Log out"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="bg-violet-600 hover:bg-violet-700 text-white text-sm font-semibold px-3 py-1 rounded transition-colors"
|
||||||
|
aria-label="Log in"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
|
<RootHeader />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
<TanStackRouterDevtools />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
205
src/routes/login.tsx
Normal file
205
src/routes/login.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "@/context/auth/AuthContext";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { ClientResponseError } from "pocketbase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login and signup page for authentication.
|
||||||
|
* Allows users to log in or create a new account.
|
||||||
|
*/
|
||||||
|
export const Route = createFileRoute("/login")({
|
||||||
|
component: LoginPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const { login, signup, user, isLoading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [mode, setMode] = useState<"login" | "signup">("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
passwordConfirm?: string;
|
||||||
|
}>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
navigate({ to: "/" });
|
||||||
|
}
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (mode === "login") {
|
||||||
|
await login(email, password);
|
||||||
|
} else {
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
setFieldErrors({ passwordConfirm: "Passwords do not match" });
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await signup(email, password, passwordConfirm);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ClientResponseError) {
|
||||||
|
setFieldErrors({
|
||||||
|
email: err.response.data.email?.message,
|
||||||
|
password: err.response.data.password?.message,
|
||||||
|
passwordConfirm: err.response.data.passwordConfirm?.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError((err as Error)?.message || "Authentication failed");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col items-center justify-center min-h-screen bg-slate-900">
|
||||||
|
<form
|
||||||
|
className="bg-slate-800 p-8 rounded-lg shadow-lg w-full max-w-md flex flex-col gap-6"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
aria-busy={submitting || isLoading}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-100 mb-2 text-center">
|
||||||
|
{mode === "login" ? "Sign In" : "Sign Up"}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="email" className="text-slate-200 font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="px-3 py-2 rounded bg-slate-700 text-slate-100 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={submitting || isLoading}
|
||||||
|
aria-invalid={!!fieldErrors.email}
|
||||||
|
aria-describedby={fieldErrors.email ? "email-error" : undefined}
|
||||||
|
/>
|
||||||
|
{fieldErrors.email && (
|
||||||
|
<div
|
||||||
|
className="text-red-400 text-xs mt-1"
|
||||||
|
id="email-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{fieldErrors.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="password" className="text-slate-200 font-medium">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={
|
||||||
|
mode === "login" ? "current-password" : "new-password"
|
||||||
|
}
|
||||||
|
required
|
||||||
|
className="px-3 py-2 rounded bg-slate-700 text-slate-100 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={submitting || isLoading}
|
||||||
|
aria-invalid={!!fieldErrors.password}
|
||||||
|
aria-describedby={
|
||||||
|
fieldErrors.password ? "password-error" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{fieldErrors.password && (
|
||||||
|
<div
|
||||||
|
className="text-red-400 text-xs mt-1"
|
||||||
|
id="password-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{fieldErrors.password}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{mode === "signup" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor="passwordConfirm"
|
||||||
|
className="text-slate-200 font-medium"
|
||||||
|
>
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passwordConfirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
className="px-3 py-2 rounded bg-slate-700 text-slate-100 focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
|
disabled={submitting || isLoading}
|
||||||
|
aria-invalid={!!fieldErrors.passwordConfirm}
|
||||||
|
aria-describedby={
|
||||||
|
fieldErrors.passwordConfirm
|
||||||
|
? "passwordConfirm-error"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{fieldErrors.passwordConfirm && (
|
||||||
|
<div
|
||||||
|
className="text-red-400 text-xs mt-1"
|
||||||
|
id="passwordConfirm-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{fieldErrors.passwordConfirm}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!fieldErrors.email &&
|
||||||
|
!fieldErrors.password &&
|
||||||
|
!fieldErrors.passwordConfirm &&
|
||||||
|
error && (
|
||||||
|
<div className="text-red-400 text-sm text-center" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-violet-600 hover:bg-violet-700 text-white font-semibold py-2 rounded transition-colors disabled:opacity-60"
|
||||||
|
disabled={submitting || isLoading}
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? mode === "login"
|
||||||
|
? "Signing In..."
|
||||||
|
: "Signing Up..."
|
||||||
|
: mode === "login"
|
||||||
|
? "Sign In"
|
||||||
|
: "Sign Up"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-violet-400 hover:underline text-sm mt-2"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === "login" ? "signup" : "login");
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
}}
|
||||||
|
disabled={submitting || isLoading}
|
||||||
|
>
|
||||||
|
{mode === "login"
|
||||||
|
? "Don't have an account? Sign up."
|
||||||
|
: "Already have an account? Sign in."}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user