diff options
Diffstat (limited to 'garden/src')
| -rw-r--r-- | garden/src/api.ts | 35 | ||||
| -rw-r--r-- | garden/src/components/Layout.tsx | 16 | ||||
| -rw-r--r-- | garden/src/pages/account/reactivate.tsx | 46 | ||||
| -rw-r--r-- | garden/src/pages/account/verify.tsx | 52 | ||||
| -rw-r--r-- | garden/src/pages/login.tsx | 49 | ||||
| -rw-r--r-- | garden/src/pages/register.tsx | 59 | ||||
| -rw-r--r-- | garden/src/routes.ts | 4 | ||||
| -rw-r--r-- | garden/src/store/auth.ts | 117 | ||||
| -rw-r--r-- | garden/src/styles/layout.css | 111 |
9 files changed, 486 insertions, 3 deletions
diff --git a/garden/src/api.ts b/garden/src/api.ts new file mode 100644 index 0000000..fc090a7 --- /dev/null +++ b/garden/src/api.ts @@ -0,0 +1,35 @@ +import { API_URL } from "./config"; + +interface APIOptions { + method?: string; + body?: unknown; + token?: string | null; +} + +interface APIResponse<T> { + ok: boolean; + status: number; + data: T; +} + +export async function api<T>(path: string, options: APIOptions = {}): Promise<APIResponse<T>> { + const headers: Record<string, string> = {}; + + if (options.body) { + headers["Content-Type"] = "application/json"; + } + + if (options.token) { + headers["Authorization"] = `Bearer ${options.token}`; + } + + const response = await fetch(`${API_URL}${path}`, { + method: options.method || "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const data = await response.json(); + + return { ok: response.ok, status: response.status, data }; +}
\ No newline at end of file diff --git a/garden/src/components/Layout.tsx b/garden/src/components/Layout.tsx index 7c3a56a..15a703e 100644 --- a/garden/src/components/Layout.tsx +++ b/garden/src/components/Layout.tsx @@ -1,13 +1,16 @@ -import type { JSX } from "solid-js"; +import { type JSX, Show, onMount } from "solid-js"; import { A } from "@solidjs/router"; import Sidebar from "./Sidebar"; import NavSection from "./NavSection"; +import { auth } from "../store/auth"; interface LayoutProps { children: JSX.Element; } export default function Layout(props: LayoutProps) { + onMount(() => auth.initialize()); + return ( <> <header class="site-header"> @@ -31,8 +34,15 @@ export default function Layout(props: LayoutProps) { <Sidebar> <NavSection title="Account" accent="pink"> <ul> - <li><A href="/login">Log In</A></li> - <li><A href="/register">Register</A></li> + <Show when={auth.user()} fallback={ + <> + <li><A href="/login">Log In</A></li> + <li><A href="/register">Register</A></li> + </> + }> + <li><A href="/account">My Account</A></li> + <li><button type="button" class="sidebar-logout" onClick={() => auth.logout()}>Log Out</button></li> + </Show> </ul> </NavSection> <NavSection title="Community" accent="cyan"> diff --git a/garden/src/pages/account/reactivate.tsx b/garden/src/pages/account/reactivate.tsx new file mode 100644 index 0000000..e415f04 --- /dev/null +++ b/garden/src/pages/account/reactivate.tsx @@ -0,0 +1,46 @@ +import { createSignal } from "solid-js"; +import { A } from "@solidjs/router"; +import { auth } from "../../store/auth"; + +export default function Reactivate() { + const [email, setEmail] = createSignal(""); + const [message, setMessage] = createSignal(""); + const [error, setError] = createSignal(""); + const [submitting, setSubmitting] = createSignal(false); + + async function handleSubmit(event: Event) { + event.preventDefault(); + setError(""); + setMessage(""); + setSubmitting(true); + + const result = await auth.reactivate(email()); + setSubmitting(false); + + if (result) { + setError(result); + } else { + setMessage("A new verification email has been sent. Please check your inbox."); + } + } + + return ( + <section> + <h2 class="page-title">Resend Verification</h2> + {error() && <div class="form-error">{error()}</div>} + {message() && <div class="form-success">{message()}</div>} + <form class="auth-form" onSubmit={handleSubmit}> + <label class="form-field"> + <span>Email</span> + <input type="email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} required /> + </label> + <button type="submit" class="form-button" disabled={submitting()}> + {submitting() ? "Sending..." : "Resend Verification Email"} + </button> + </form> + <p class="form-footer"> + Already verified? <A href="/login">Log In</A> + </p> + </section> + ); +}
\ No newline at end of file diff --git a/garden/src/pages/account/verify.tsx b/garden/src/pages/account/verify.tsx new file mode 100644 index 0000000..03214ea --- /dev/null +++ b/garden/src/pages/account/verify.tsx @@ -0,0 +1,52 @@ +import { createSignal, onMount, Show } from "solid-js"; +import { A, useSearchParams } from "@solidjs/router"; +import { auth } from "../../store/auth"; + +export default function Verify() { + const [searchParams] = useSearchParams(); + const [message, setMessage] = createSignal(""); + const [error, setError] = createSignal(""); + const [loading, setLoading] = createSignal(false); + + onMount(async () => { + const token = searchParams.token; + if (!token) return; + + setLoading(true); + const result = await auth.verify(token as string, "activation"); + setLoading(false); + + if (result) { + setError(result); + } else { + setMessage("Your email has been verified successfully. You can now log in."); + } + }); + + return ( + <section> + <h2 class="page-title">Verify Account</h2> + <Show when={loading()}> + <p>Verifying your account...</p> + </Show> + <Show when={error()}> + <div class="form-error">{error()}</div> + <p class="form-footer"> + <A href="/account/reactivate">Request a new verification email</A> + </p> + </Show> + <Show when={message()}> + <div class="form-success">{message()}</div> + <p class="form-footer"> + <A href="/login">Log In</A> + </p> + </Show> + <Show when={!searchParams.token && !loading()}> + <p>Please check your email for a verification link.</p> + <p class="form-footer"> + Didn't receive an email? <A href="/account/reactivate">Request a new one</A> + </p> + </Show> + </section> + ); +}
\ No newline at end of file diff --git a/garden/src/pages/login.tsx b/garden/src/pages/login.tsx new file mode 100644 index 0000000..23f94a0 --- /dev/null +++ b/garden/src/pages/login.tsx @@ -0,0 +1,49 @@ +import { createSignal } from "solid-js"; +import { A, useNavigate } from "@solidjs/router"; +import { auth } from "../store/auth"; + +export default function Login() { + const navigate = useNavigate(); + const [username, setUsername] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [error, setError] = createSignal(""); + const [submitting, setSubmitting] = createSignal(false); + + async function handleSubmit(event: Event) { + event.preventDefault(); + setError(""); + setSubmitting(true); + + const result = await auth.login(username(), password()); + setSubmitting(false); + + if (result) { + setError(result); + } else { + navigate("/"); + } + } + + return ( + <section> + <h2 class="page-title">Log In</h2> + {error() && <div class="form-error">{error()}</div>} + <form class="auth-form" onSubmit={handleSubmit}> + <label class="form-field"> + <span>Username</span> + <input type="text" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} required /> + </label> + <label class="form-field"> + <span>Password</span> + <input type="password" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} required /> + </label> + <button type="submit" class="form-button" disabled={submitting()}> + {submitting() ? "Logging in..." : "Log In"} + </button> + </form> + <p class="form-footer"> + Don't have an account? <A href="/register">Register</A> + </p> + </section> + ); +}
\ No newline at end of file diff --git a/garden/src/pages/register.tsx b/garden/src/pages/register.tsx new file mode 100644 index 0000000..6bb312d --- /dev/null +++ b/garden/src/pages/register.tsx @@ -0,0 +1,59 @@ +import { createSignal } from "solid-js"; +import { A, useNavigate } from "@solidjs/router"; +import { auth } from "../store/auth"; + +export default function Register() { + const navigate = useNavigate(); + const [username, setUsername] = createSignal(""); + const [email, setEmail] = createSignal(""); + const [displayName, setDisplayName] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [error, setError] = createSignal(""); + const [submitting, setSubmitting] = createSignal(false); + + async function handleSubmit(event: Event) { + event.preventDefault(); + setError(""); + setSubmitting(true); + + const result = await auth.register(username(), email(), password(), displayName()); + setSubmitting(false); + + if (result) { + setError(result); + } else { + navigate("/account/verify"); + } + } + + return ( + <section> + <h2 class="page-title">Register</h2> + {error() && <div class="form-error">{error()}</div>} + <form class="auth-form" onSubmit={handleSubmit}> + <label class="form-field"> + <span>Username</span> + <input type="text" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} required /> + </label> + <label class="form-field"> + <span>Email</span> + <input type="email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} required /> + </label> + <label class="form-field"> + <span>Display Name</span> + <input type="text" value={displayName()} onInput={(e) => setDisplayName(e.currentTarget.value)} /> + </label> + <label class="form-field"> + <span>Password</span> + <input type="password" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} required /> + </label> + <button type="submit" class="form-button" disabled={submitting()}> + {submitting() ? "Registering..." : "Register"} + </button> + </form> + <p class="form-footer"> + Already have an account? <A href="/login">Log In</A> + </p> + </section> + ); +}
\ No newline at end of file diff --git a/garden/src/routes.ts b/garden/src/routes.ts index 2e3487d..6ec209e 100644 --- a/garden/src/routes.ts +++ b/garden/src/routes.ts @@ -5,5 +5,9 @@ import Home from "./pages/home"; export const routes: RouteDefinition[] = [ { path: "/", component: Home }, + { path: "/login", component: lazy(() => import("./pages/login")) }, + { path: "/register", component: lazy(() => import("./pages/register")) }, + { path: "/account/verify", component: lazy(() => import("./pages/account/verify")) }, + { path: "/account/reactivate", component: lazy(() => import("./pages/account/reactivate")) }, { path: "**", component: lazy(() => import("./errors/404")) }, ]; diff --git a/garden/src/store/auth.ts b/garden/src/store/auth.ts new file mode 100644 index 0000000..c7b4e81 --- /dev/null +++ b/garden/src/store/auth.ts @@ -0,0 +1,117 @@ +import { createSignal } from "solid-js"; +import { api } from "../api"; + +interface User { + id: number; + username: string; + email: string; + display_name: string; + bio: string; + birthday: string | null; + avatar_url: string; + blinkie_url: string; + website: string; + location: string; + pronouns: string; + signature: string; + role: string; + created_at: string; +} + +interface AuthResponse { + token: string; + user: User; +} + +interface MessageResponse { + message: string; +} + +interface ErrorResponse { + error: string; +} + +const [user, setUser] = createSignal<User | null>(null); +const [token, setToken] = createSignal<string | null>(localStorage.getItem("token")); +const [loading, setLoading] = createSignal(true); + +async function initialize() { + const stored = token(); + if (!stored) { + setLoading(false); + return; + } + + try { + const response = await api<User>("/auth/me", { token: stored }); + if (response.ok) { + setUser(response.data); + } else { + localStorage.removeItem("token"); + setToken(null); + } + } catch { + localStorage.removeItem("token"); + setToken(null); + } + setLoading(false); +} + +async function login(username: string, password: string): Promise<string | null> { + const response = await api<AuthResponse | ErrorResponse>("/auth/login", { + method: "POST", + body: { username, password }, + }); + + if (response.ok) { + const data = response.data as AuthResponse; + localStorage.setItem("token", data.token); + setToken(data.token); + setUser(data.user); + return null; + } + + return (response.data as ErrorResponse).error; +} + +async function register(username: string, email: string, password: string, displayName: string): Promise<string | null> { + const response = await api<MessageResponse | ErrorResponse>("/auth/register", { + method: "POST", + body: { username, email, password, display_name: displayName }, + }); + + if (response.ok) return null; + return (response.data as ErrorResponse).error; +} + +async function verify(verificationToken: string, type: string): Promise<string | null> { + const response = await api<MessageResponse | ErrorResponse>("/auth/verify", { + method: "POST", + body: { token: verificationToken, type }, + }); + + if (response.ok) return null; + return (response.data as ErrorResponse).error; +} + +async function reactivate(email: string): Promise<string | null> { + const response = await api<MessageResponse | ErrorResponse>("/auth/reactivate", { + method: "POST", + body: { email }, + }); + + if (response.ok) return null; + return (response.data as ErrorResponse).error; +} + +async function logout() { + const stored = token(); + if (stored) { + await api("/auth/logout", { method: "POST", token: stored }); + } + localStorage.removeItem("token"); + setToken(null); + setUser(null); +} + +export const auth = { user, token, loading, initialize, login, register, verify, reactivate, logout };
\ No newline at end of file diff --git a/garden/src/styles/layout.css b/garden/src/styles/layout.css index 527ca12..cdc3ed6 100644 --- a/garden/src/styles/layout.css +++ b/garden/src/styles/layout.css @@ -276,4 +276,115 @@ a:hover { .nav-section-body li.placeholder::before { display: none; +} + +.page-title { + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 3px; + color: var(--color-text-bright); + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--color-border); +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 360px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 3px; +} + +.form-field span { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--color-text-muted); +} + +.form-field input { + background: var(--color-bg); + border: 1px solid var(--color-border); + padding: 6px 8px; + font-family: var(--font-body); + font-size: 13px; + color: var(--color-text); + outline: none; +} + +.form-field input:focus { + border-color: var(--color-purple); +} + +.form-button { + background: var(--color-purple); + border: none; + padding: 8px 16px; + font-family: var(--font-display); + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 2px; + color: #fff; + cursor: pointer; + margin-top: 4px; +} + +.form-button:hover { + background: #8a5cf0; +} + +.form-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-error { + background: rgba(255, 80, 80, 0.1); + border: 1px solid rgba(255, 80, 80, 0.3); + color: #ff6b6b; + padding: 8px 10px; + font-size: 12px; + margin-bottom: 10px; + max-width: 360px; +} + +.form-success { + background: rgba(163, 230, 53, 0.1); + border: 1px solid rgba(163, 230, 53, 0.3); + color: var(--color-green); + padding: 8px 10px; + font-size: 12px; + margin-bottom: 10px; + max-width: 360px; +} + +.form-footer { + margin-top: 12px; + font-size: 12px; + color: var(--color-text-muted); +} + +.sidebar-logout { + background: none; + border: none; + padding: 0; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-link); + cursor: pointer; + text-decoration: none; +} + +.sidebar-logout:hover { + color: var(--color-pink); }
\ No newline at end of file |
