add middlewares, handlers and database models
This commit is contained in:
parent
979a415b95
commit
7e288ea67a
24 changed files with 1045 additions and 14 deletions
30
internal/ui/blocks/themeswitcher.templ
Normal file
30
internal/ui/blocks/themeswitcher.templ
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package blocks
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
|
||||
)
|
||||
|
||||
type ThemeSwitcherProps struct {
|
||||
Class string
|
||||
}
|
||||
|
||||
// ThemeSwitcher renders only the UI button.
|
||||
// The interactive script is handled globally in the layout.
|
||||
templ ThemeSwitcher(props ...ThemeSwitcherProps) {
|
||||
{{ var p ThemeSwitcherProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Size: button.SizeIcon,
|
||||
Variant: button.VariantGhost,
|
||||
Class: p.Class,
|
||||
Attributes: templ.Attributes{
|
||||
"data-theme-switcher": "true",
|
||||
"aria-label": "Toggle theme",
|
||||
},
|
||||
}) {
|
||||
@icon.Eclipse(icon.Props{Size: 20})
|
||||
}
|
||||
}
|
||||
35
internal/ui/components/csrf/csrf.templ
Normal file
35
internal/ui/components/csrf/csrf.templ
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package csrf
|
||||
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ctxkeys"
|
||||
|
||||
// Token renders a hidden CSRF token input for form submissions.
|
||||
//
|
||||
// Usage in forms:
|
||||
//
|
||||
// <form action="/auth/login" method="POST">
|
||||
// @csrf.Token()
|
||||
// <input name="email" type="email">
|
||||
// <button>Login</button>
|
||||
// </form>
|
||||
//
|
||||
// Security: This token protects against Cross-Site Request Forgery (CSRF) attacks.
|
||||
// The token is validated server-side via middleware.CSRFProtection middleware.
|
||||
//
|
||||
// How it works:
|
||||
// 1. Server generates random token and stores in HttpOnly cookie
|
||||
// 2. Server renders token in this hidden input
|
||||
// 3. On form submit, both cookie and form field are sent
|
||||
// 4. Server compares: cookie token == form token (constant-time comparison)
|
||||
// 5. If match → request allowed, if mismatch → 403 Forbidden
|
||||
//
|
||||
// Why this protects against CSRF:
|
||||
// - Attacker can trigger cookie to be sent (automatic browser behavior)
|
||||
// - But attacker CANNOT read cookie value (HttpOnly + SameSite)
|
||||
// - Therefore attacker CANNOT set correct form field value
|
||||
// - Server rejects request because tokens don't match
|
||||
//
|
||||
// This is called "Double Submit Cookie" pattern - industry standard used by
|
||||
// Stripe, GitHub, Shopify, and recommended by OWASP.
|
||||
templ Token() {
|
||||
<input type="hidden" name="csrf_token" value={ ctxkeys.CSRFToken(ctx) }/>
|
||||
}
|
||||
14
internal/ui/layouts/auth.templ
Normal file
14
internal/ui/layouts/auth.templ
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package layouts
|
||||
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/blocks"
|
||||
|
||||
templ Auth(seo SEOProps) {
|
||||
@Base(seo) {
|
||||
<div class="relative">
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
@blocks.ThemeSwitcher()
|
||||
</div>
|
||||
{ children... }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
169
internal/ui/layouts/base.templ
Normal file
169
internal/ui/layouts/base.templ
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package layouts
|
||||
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/input"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/dialog"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/sidebar"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/collapsible"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ctxkeys"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/dropdown"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/popover"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/toast"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/calendar"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/datepicker"
|
||||
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/progress"
|
||||
import "fmt"
|
||||
import "time"
|
||||
|
||||
type SEOProps struct {
|
||||
Title string
|
||||
Description string
|
||||
Path string
|
||||
}
|
||||
|
||||
templ Base(props ...SEOProps) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<meta name="csrf-token" content={ ctxkeys.CSRFToken(ctx) }/>
|
||||
if len(props) > 0 {
|
||||
@seo(props[0])
|
||||
} else {
|
||||
{{ cfg := ctxkeys.Config(ctx) }}
|
||||
<title>{ cfg.AppName }</title>
|
||||
}
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon/favicon.ico"/>
|
||||
<link href={ "/assets/css/output.css?v=" + templ.EscapeString(fmt.Sprintf("%d", time.Now().Unix())) } rel="stylesheet"/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" integrity="sha384-ZBXiYtYQ6hJ2Y0ZNoYuI+Nq5MqWBr+chMrS/RkXpNzQCApHEhOt2aY8EJgqwHLkJ" crossorigin="anonymous"></script>
|
||||
// Component scripts
|
||||
@input.Script()
|
||||
@sidebar.Script()
|
||||
@dialog.Script()
|
||||
@collapsible.Script()
|
||||
@dropdown.Script()
|
||||
@popover.Script()
|
||||
@toast.Script()
|
||||
@calendar.Script()
|
||||
@datepicker.Script()
|
||||
@progress.Script()
|
||||
// Site-wide enhancements
|
||||
@themeScript()
|
||||
// Must run before body to prevent flash
|
||||
@smoothScrollScript()
|
||||
// HTMX CSRF configuration
|
||||
@htmxCSRFScript()
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
{ children... }
|
||||
// Global Toast Container
|
||||
<div id="toast-container"></div>
|
||||
// Built with goilerplate Badge
|
||||
<a
|
||||
href="https://goilerplate.com"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="fixed bottom-4 right-4 z-40 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-full bg-background/80 backdrop-blur-sm transition-all hover:scale-105 hover:shadow-md flex items-center gap-2"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-foreground animate-pulse"></span>
|
||||
Built with goilerplate
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ seo(props SEOProps) {
|
||||
{{ cfg := ctxkeys.Config(ctx) }}
|
||||
{{ baseURL := cfg.AppURL }}
|
||||
{{ appName := cfg.AppName }}
|
||||
{{ appTagline := cfg.AppTagline }}
|
||||
{{ fullTitle := props.Title + " - " + appName }}
|
||||
@templ.Fragment("seo-title") {
|
||||
<title id="page-title" hx-swap-oob="true">{ fullTitle }</title>
|
||||
}
|
||||
<meta name="description" content={ props.Description }/>
|
||||
// Author
|
||||
<meta name="author" content={ appName }/>
|
||||
// Robots Tags
|
||||
<meta name="robots" content="index, follow"/>
|
||||
// Canonical URL
|
||||
<link rel="canonical" href={ baseURL + props.Path }/>
|
||||
// OpenGraph Tags
|
||||
<meta property="og:title" content={ fullTitle }/>
|
||||
<meta property="og:description" content={ props.Description }/>
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:url" content={ baseURL + props.Path }/>
|
||||
<meta property="og:site_name" content={ appName }/>
|
||||
// OpenGraph Image
|
||||
<meta property="og:image" content={ baseURL + "/assets/img/social-preview.png" }/>
|
||||
<meta property="og:image:width" content="1200"/>
|
||||
<meta property="og:image:height" content="630"/>
|
||||
<meta property="og:image:alt" content={ appName + " - " + appTagline }/>
|
||||
// Twitter Card
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
<meta name="twitter:title" content={ fullTitle }/>
|
||||
<meta name="twitter:description" content={ props.Description }/>
|
||||
<meta name="twitter:image" content={ baseURL + "/assets/img/social-preview.png" }/>
|
||||
<meta name="twitter:image:alt" content={ appName + " - " + appTagline }/>
|
||||
// Theme Color
|
||||
<meta name="theme-color" content="#000000"/>
|
||||
}
|
||||
|
||||
templ themeScript() {
|
||||
<script nonce={ templ.GetNonce(ctx) }>
|
||||
// Apply saved theme or system preference on load
|
||||
if (localStorage.theme === 'dark' || (!localStorage.theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
// Theme toggle handler
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-theme-switcher]')) {
|
||||
e.preventDefault();
|
||||
const isDark = document.documentElement.classList.toggle('dark');
|
||||
localStorage.theme = isDark ? 'dark' : 'light';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
templ smoothScrollScript() {
|
||||
// Smooth scrolling for anchor links - works site-wide
|
||||
<script nonce={ templ.GetNonce(ctx) }>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
// Only prevent default for same-page anchors
|
||||
const href = this.getAttribute('href');
|
||||
if (href && href !== '#' && href.startsWith('#')) {
|
||||
const target = document.querySelector(href);
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
templ htmxCSRFScript() {
|
||||
// Configure HTMX to automatically send CSRF token with all requests
|
||||
<script nonce={ templ.GetNonce(ctx) }>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Listen for htmx requests and add CSRF token header
|
||||
document.body.addEventListener('htmx:configRequest', function(event) {
|
||||
// Get CSRF token from meta tag
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta) {
|
||||
// Add token as X-CSRF-Token header to all HTMX requests
|
||||
event.detail.headers['X-CSRF-Token'] = meta.getAttribute('content');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
76
internal/ui/pages/auth.templ
Normal file
76
internal/ui/pages/auth.templ
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/form"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgething/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ Auth(errorMsg string) {
|
||||
{{ cfg := ctxkeys.Config(ctx) }}
|
||||
@layouts.Auth(layouts.SEOProps{
|
||||
Title: "Welcome",
|
||||
Description: "Sign in or create your account",
|
||||
Path: ctxkeys.URLPath(ctx),
|
||||
}) {
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mb-8">
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantSecondary,
|
||||
Size: button.SizeLg,
|
||||
Href: "/",
|
||||
}) {
|
||||
@icon.Layers()
|
||||
{ cfg.AppName }
|
||||
}
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold">Welcome</h2>
|
||||
<p class="text-muted-foreground mt-2">Sign in or create your account</p>
|
||||
</div>
|
||||
<form action="/auth/magic-link" method="POST" class="space-y-6">
|
||||
@csrf.Token()
|
||||
@form.Item() {
|
||||
@label.Label(label.Props{
|
||||
For: "email",
|
||||
Class: "block mb-2",
|
||||
}) {
|
||||
Email
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
ID: "email",
|
||||
Name: "email",
|
||||
Type: input.TypeEmail,
|
||||
Placeholder: "name@example.com",
|
||||
HasError: errorMsg != "",
|
||||
Attributes: templ.Attributes{"autofocus": ""},
|
||||
})
|
||||
if errorMsg != "" {
|
||||
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||
{ errorMsg }
|
||||
}
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Type: button.TypeSubmit,
|
||||
FullWidth: true,
|
||||
}) {
|
||||
Continue with Email
|
||||
}
|
||||
</form>
|
||||
<!-- Password option -->
|
||||
<p class="mt-6 text-center text-sm text-muted-foreground">
|
||||
<a href="/auth/password" class="text-primary hover:underline">
|
||||
Sign in with password instead
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue