summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-05 15:18:18 +0530
committerBobby <[email protected]>2026-03-05 15:18:18 +0530
commit01e69afac6a486dacf5fff6c3239a514c3cfb1ab (patch)
tree424a0c6f65447c4312b464f2f1dea7580c784bc4
parentc3677e1a4dc0b662579c82755aef6e0e9573eee8 (diff)
downloadpagoda-01e69afac6a486dacf5fff6c3239a514c3cfb1ab.tar.xz
pagoda-01e69afac6a486dacf5fff6c3239a514c3cfb1ab.zip
feat: implement user authentication flow with login, registration, and email verification
-rw-r--r--garden/src/api.ts35
-rw-r--r--garden/src/components/Layout.tsx16
-rw-r--r--garden/src/pages/account/reactivate.tsx46
-rw-r--r--garden/src/pages/account/verify.tsx52
-rw-r--r--garden/src/pages/login.tsx49
-rw-r--r--garden/src/pages/register.tsx59
-rw-r--r--garden/src/routes.ts4
-rw-r--r--garden/src/store/auth.ts117
-rw-r--r--garden/src/styles/layout.css111
-rw-r--r--shrine/utils/env/setter.go7
10 files changed, 491 insertions, 5 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
diff --git a/shrine/utils/env/setter.go b/shrine/utils/env/setter.go
index 4aad059..5ac8ccd 100644
--- a/shrine/utils/env/setter.go
+++ b/shrine/utils/env/setter.go
@@ -8,6 +8,11 @@ import (
)
func setFieldFromEnv(field reflect.Value, envKey, defaultVal string) {
+ if field.Type() == reflect.TypeFor[time.Duration]() {
+ setDurationField(field, envKey, defaultVal)
+ return
+ }
+
switch field.Kind() {
case reflect.String:
field.SetString(getEnv(envKey, defaultVal))
@@ -25,8 +30,6 @@ func setFieldFromEnv(field reflect.Value, envKey, defaultVal string) {
field.SetFloat(getEnvFloat(envKey, defaultFloat))
case reflect.Slice:
setSliceField(field, envKey, defaultVal)
- default:
- setDurationField(field, envKey, defaultVal)
}
}