diff --git a/.vscode/settings.json b/.vscode/settings.json index c567e8e..211503f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,16 @@ }, "files.associations": { "*.css": "tailwindcss" + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/.cursor": true, + "**/.vscode": true, + "**/_fresh": true, + "**/node_modules": true } } diff --git a/components/Header.tsx b/components/Header.tsx index d2915d2..6bcef14 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -7,7 +7,6 @@ const loggedOutLinks = [ const loggedInLinks = [ { href: "/", label: "Home" }, { href: "/dashboard", label: "Dashboard" }, - { href: "/sign-out", label: "Sign out" }, ]; export interface Props { @@ -29,6 +28,13 @@ export default function Header(props: Props) { {l.label} ))} + {props.loggedIn && ( +
  • +
    + +
    +
  • + )} diff --git a/islands/PasswordConfirmationFields.tsx b/islands/PasswordConfirmationFields.tsx new file mode 100644 index 0000000..2667b8e --- /dev/null +++ b/islands/PasswordConfirmationFields.tsx @@ -0,0 +1,67 @@ +import { useRef, useState } from "preact/hooks"; + +const mismatchMessage = "Passwords do not match."; + +export default function PasswordConfirmationFields() { + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const confirmInput = useRef(null); + const passwordsDoNotMatch = passwordConfirm.length > 0 && + password !== passwordConfirm; + + function updatePassword(value: string) { + setPassword(value); + const confirmation = confirmInput.current; + if (confirmation) { + confirmation.setCustomValidity( + confirmation.value && value !== confirmation.value + ? mismatchMessage + : "", + ); + } + } + + function updatePasswordConfirm(value: string) { + setPasswordConfirm(value); + confirmInput.current?.setCustomValidity( + value && password !== value ? mismatchMessage : "", + ); + } + + return ( +
    + updatePassword(event.currentTarget.value)} + /> +
    + updatePasswordConfirm(event.currentTarget.value)} + /> + {passwordsDoNotMatch && ( +

    + {mismatchMessage} +

    + )} +
    +
    + ); +} diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..392db5c --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,27 @@ +import { deleteCookie, getCookies, setCookie } from "@std/http/cookie"; + +export function setAuthCookie( + headers: Headers, +) { + setCookie(headers, { + name: "auth", + value: "ok", + maxAge: 60 * 60, + sameSite: "Lax", + path: "/", + secure: true, + httpOnly: true, + }); +} + +export function unsetAuthCookie(headers: Headers) { + deleteCookie(headers, "auth", { + path: "/", + }); +} + +export function isLoggedIn(req: Request): boolean { + const cookies = getCookies(req.headers); + const authCookie = cookies["auth"]; + return authCookie === "ok"; +} diff --git a/routes/(app)/_middleware.ts b/routes/(app)/_middleware.ts new file mode 100644 index 0000000..5e9ed32 --- /dev/null +++ b/routes/(app)/_middleware.ts @@ -0,0 +1,9 @@ +import { define } from "../../utils.ts"; + +export default define.middleware((ctx) => { + if (!ctx.state.loggedIn) { + return ctx.redirect("/sign-in", 303); + } + + return ctx.next(); +}); diff --git a/routes/(app)/dashboard.tsx b/routes/(app)/dashboard.tsx new file mode 100644 index 0000000..0b3d679 --- /dev/null +++ b/routes/(app)/dashboard.tsx @@ -0,0 +1,10 @@ +import Headline from "../../components/Headline.tsx"; +import { define } from "../../utils.ts"; + +export default define.page(function Dashboard() { + return ( +
    + Dashboard +
    + ); +}); diff --git a/routes/sign-out.tsx b/routes/(app)/sign-out.tsx similarity index 52% rename from routes/sign-out.tsx rename to routes/(app)/sign-out.tsx index c6b7dfd..65794ef 100644 --- a/routes/sign-out.tsx +++ b/routes/(app)/sign-out.tsx @@ -1,13 +1,12 @@ -import { define, unsetAuthCookie } from "../utils.ts"; +import { unsetAuthCookie } from "../../lib/auth.ts"; +import { define } from "../../utils.ts"; export const handler = define.handlers({ - GET(ctx) { + POST(ctx) { const headers = new Headers(); unsetAuthCookie(headers); - const url = new URL(ctx.req.url); - url.pathname = "/"; - headers.set("location", url.toString()); + headers.set("location", new URL("/", ctx.req.url).toString()); return new Response(null, { status: 303, headers, diff --git a/routes/(guest)/_middleware.ts b/routes/(guest)/_middleware.ts new file mode 100644 index 0000000..fa0eca4 --- /dev/null +++ b/routes/(guest)/_middleware.ts @@ -0,0 +1,9 @@ +import { define } from "../../utils.ts"; + +export default define.middleware((ctx) => { + if (ctx.state.loggedIn) { + return ctx.redirect("/dashboard", 303); + } + + return ctx.next(); +}); diff --git a/routes/sign-in.tsx b/routes/(guest)/sign-in.tsx similarity index 76% rename from routes/sign-in.tsx rename to routes/(guest)/sign-in.tsx index 4812b50..e115b1a 100644 --- a/routes/sign-in.tsx +++ b/routes/(guest)/sign-in.tsx @@ -1,8 +1,9 @@ import { z } from "zod"; -import { backendSignIn } from "../backend/sign-in.ts"; -import Headline from "../components/Headline.tsx"; -import { SignInForm } from "../components/SignInForm.tsx"; -import { define, isLoggedIn, setAuthCookie } from "../utils.ts"; +import { backendSignIn } from "../../backend/sign-in.ts"; +import Headline from "../../components/Headline.tsx"; +import { SignInForm } from "../../components/SignInForm.tsx"; +import { setAuthCookie } from "../../lib/auth.ts"; +import { define } from "../../utils.ts"; const loginSchema = z.object({ email: z.string(), @@ -10,13 +11,7 @@ const loginSchema = z.object({ }); export const handler = define.handlers({ - GET(ctx) { - if (isLoggedIn(ctx.req)) { - const url = new URL(ctx.req.url); - url.pathname = "/dashboard"; - return Response.redirect(url, 303); - } - + GET(_ctx) { return { data: { email: "", @@ -52,7 +47,7 @@ export const handler = define.handlers({ } const headers = new Headers(); - setAuthCookie(ctx.req, headers); + setAuthCookie(headers); const url = new URL(ctx.req.url); url.pathname = "/dashboard"; diff --git a/routes/(guest)/sign-up.tsx b/routes/(guest)/sign-up.tsx new file mode 100644 index 0000000..61475f7 --- /dev/null +++ b/routes/(guest)/sign-up.tsx @@ -0,0 +1,97 @@ +import { z } from "zod"; +import Headline from "../../components/Headline.tsx"; +import PasswordConfirmationFields from "../../islands/PasswordConfirmationFields.tsx"; +import { define } from "../../utils.ts"; + +const signUpSchema = z.object({ + email: z.email("Please enter a valid email address.").trim(), + firstName: z.string().trim().min(1, "Please enter your first name."), + lastName: z.string().trim().min(1, "Please enter your last name."), + password: z.string().min(8, "Password must be at least 8 characters long."), + passwordConfirm: z.string(), +}).refine((data) => data.password === data.passwordConfirm, { + message: "Passwords do not match.", + path: ["passwordConfirm"], +}); + +const emptyForm = { + email: "", + firstName: "", + lastName: "", + error: undefined as string | undefined, +}; + +export const handler = define.handlers({ + GET() { + return { data: emptyForm }; + }, + + async POST(ctx) { + const formData = await ctx.req.formData(); + const result = signUpSchema.safeParse({ + email: formData.get("email"), + firstName: formData.get("firstName"), + lastName: formData.get("lastName"), + password: formData.get("password"), + passwordConfirm: formData.get("passwordConfirm"), + }); + + if (!result.success) { + return { + data: { + email: formData.get("email")?.toString() ?? "", + firstName: formData.get("firstName")?.toString() ?? "", + lastName: formData.get("lastName")?.toString() ?? "", + error: result.error.issues[0].message, + }, + }; + } + + const { passwordConfirm: _passwordConfirm, ...registration } = result.data; + // Use registration to register user + void registration; + + return ctx.redirect("/sign-up", 303); + }, +}); + +export default define.page(function SignUpPage(ctx) { + const { email, firstName, lastName, error } = ctx.data; + + return ( +
    + Sign up + {error && } + +
    + + +
    + + + + ); +}); diff --git a/routes/index.tsx b/routes/(public)/index.tsx similarity index 90% rename from routes/index.tsx rename to routes/(public)/index.tsx index ef1cc7d..805cc46 100644 --- a/routes/index.tsx +++ b/routes/(public)/index.tsx @@ -1,7 +1,7 @@ import { useSignal } from "@preact/signals"; import { Head } from "fresh/runtime"; -import Counter from "../islands/Counter.tsx"; -import { define } from "../utils.ts"; +import Counter from "../../islands/Counter.tsx"; +import { define } from "../../utils.ts"; export default define.page(function Home(ctx) { const count = useSignal(3); diff --git a/routes/_app.tsx b/routes/_app.tsx index 120814d..9227f16 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -1,9 +1,8 @@ import Header from "../components/Header.tsx"; -import { define, isLoggedIn } from "../utils.ts"; +import { define } from "../utils.ts"; export default define.page(function App(ctx) { const Component = ctx.Component; - const loggedIn = isLoggedIn(ctx.req); return ( @@ -13,7 +12,7 @@ export default define.page(function App(ctx) { Deno Fresh Dummy -
    +
    diff --git a/routes/_middleware.ts b/routes/_middleware.ts new file mode 100644 index 0000000..edfb71d --- /dev/null +++ b/routes/_middleware.ts @@ -0,0 +1,7 @@ +import { isLoggedIn } from "../lib/auth.ts"; +import { define } from "../utils.ts"; + +export default define.middleware((ctx) => { + ctx.state.loggedIn = isLoggedIn(ctx.req); + return ctx.next(); +}); diff --git a/routes/api/[name].tsx b/routes/api/[name].tsx deleted file mode 100644 index 0c97248..0000000 --- a/routes/api/[name].tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { define } from "../../utils.ts"; - -export const handler = define.handlers({ - GET(ctx) { - const name = ctx.params.name; - return new Response( - `Hello, ${name.charAt(0).toUpperCase() + name.slice(1)}!`, - ); - }, -}); diff --git a/routes/dashboard.tsx b/routes/dashboard.tsx deleted file mode 100644 index ed60ea6..0000000 --- a/routes/dashboard.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import Headline from "../components/Headline.tsx"; -import { define, isLoggedIn } from "../utils.ts"; - -export const handler = define.handlers({ - GET(ctx) { - console.log("ASDF"); - if (!isLoggedIn(ctx.req)) { - const url = new URL(ctx.req.url); - url.pathname = "/sign-in"; - return Response.redirect(url, 303); - } - - return { data: {} }; - }, -}); - -export default define.page(function Dashboard(_ctx) { - return ( -
    - Dashboard -
    - ); -}); diff --git a/routes/sign-up.tsx b/routes/sign-up.tsx deleted file mode 100644 index 9842ee9..0000000 --- a/routes/sign-up.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Headline from "../components/Headline.tsx"; -import { define } from "../utils.ts"; - -export default define.page(function SignUpPage(ctx) { - return ( -
    - Sign up - -
    - - -
    - -
    - ); -}); diff --git a/utils.ts b/utils.ts index 4dc5c02..6f7222d 100644 --- a/utils.ts +++ b/utils.ts @@ -1,36 +1,10 @@ -import { deleteCookie, getCookies, setCookie } from "@std/http/cookie"; import { createDefine } from "fresh"; // This specifies the type of "ctx.state" which is used to share // data among middlewares, layouts and routes. export interface State { shared: string; + loggedIn: boolean; } export const define = createDefine(); - -export function setAuthCookie( - req: Request, - headers: Headers, -) { - const url = new URL(req.url); - setCookie(headers, { - name: "auth", - value: "ok", - maxAge: 60 * 60, - sameSite: "Lax", - domain: url.hostname, - path: "/", - secure: true, - }); -} - -export function unsetAuthCookie(headers: Headers) { - deleteCookie(headers, "auth"); -} - -export function isLoggedIn(req: Request): boolean { - const cookies = getCookies(req.headers); - const authCookie = cookies["auth"]; - return authCookie === "ok"; -}