ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #166 from paulgmiller/pmiller/clerkmanual

use clerk for login when not mocks. In mocks just use a fake user.

authored by

Paul Miller and committed by
GitHub
037e8916 223dddcc

+494 -286
+2
.gitignore
··· 29 29 30 30 #secrets 31 31 .env 32 + .envtest 33 + .envprod 32 34 33 35 tailwind/node_modules/ 34 36
+4 -100
cmd/careme/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "careme/internal/cache" 5 4 "careme/internal/config" 6 - "careme/internal/locations" 7 5 "careme/internal/logsink" 8 - "careme/internal/recipes" 9 6 "context" 10 7 _ "embed" 11 8 "flag" 12 - "fmt" 13 9 "log" 14 10 "log/slog" 15 11 "os" 16 12 "time" 17 13 18 - "github.com/alpkeskin/gotoon" 19 14 multi "github.com/samber/slog-multi" 20 15 ) 21 16 22 17 func main() { 23 - var location string 24 - var zipcode string 25 - var ingredient string 26 18 var serve, mail bool 27 19 var addr string 28 20 29 - flag.StringVar(&location, "location", "", "Location for recipe sourcing (e.g., 70100023)") 30 - flag.StringVar(&location, "l", "", "Location for recipe sourcing (short form)") 31 - flag.StringVar(&zipcode, "zipcode", "", "return location ids for a zip code.") 32 - flag.StringVar(&zipcode, "z", "", "return location ids for a zip code (short form)") 33 - flag.StringVar(&ingredient, "ingredient", "", "just list ingredients") 34 - flag.StringVar(&ingredient, "i", "", "just list ingredients (short form)") 35 - flag.BoolVar(&serve, "serve", false, "Run HTTP server mode") 21 + //left for back compat does noting 22 + flag.BoolVar(&serve, "serve", false, "dead we always serve") 36 23 flag.BoolVar(&mail, "mail", false, "Run mail sender loop") 37 24 flag.StringVar(&addr, "addr", ":8080", "Address to bind in server mode") 38 25 flag.Parse() ··· 74 61 go mailer.Iterate(ctx, 1*time.Hour) 75 62 } 76 63 77 - if serve { 78 - if err := runServer(cfg, logcfg, addr); err != nil { 79 - log.Fatalf("server error: %v", err) 80 - } 81 - return 82 - } 83 - 84 - if zipcode != "" { 85 - ls, err := locations.New(ctx, cfg) 86 - if err != nil { 87 - log.Fatalf("failed to create location server: %v", err) 88 - } 89 - locs, err := ls.GetLocationsByZip(ctx, zipcode) 90 - if err != nil { 91 - log.Fatalf("failed to get locations for zip %s: %v", zipcode, err) 92 - } 93 - fmt.Printf("Locations for zip code %s:\n", zipcode) 94 - for _, loc := range locs { 95 - fmt.Printf("- %s, %s: %s\n", loc.Name, loc.Address, loc.ID) 96 - } 97 - return 98 - } 99 - 100 - if location == "" { 101 - fmt.Println("Error: Location is required (or use -serve for web mode)") 102 - os.Exit(1) 103 - } 104 - 105 - if err := run(cfg, location, ingredient); err != nil { 106 - log.Fatalf("Error: %v", err) 107 - } 108 - } 109 - 110 - func run(cfg *config.Config, location string, ingredient string) error { 111 - ctx := context.Background() 112 - cache, err := cache.MakeCache() 113 - if err != nil { 114 - return fmt.Errorf("failed to create cache: %w", err) 115 - } 116 - 117 - generator, err := recipes.NewGenerator(cfg, cache) 118 - if err != nil { 119 - return fmt.Errorf("failed to create recipe generator: %w", err) 120 - } 121 - 122 - // just use the kroger client directly or punt all this and go pure web 123 - g, ok := generator.(*recipes.Generator) 124 - if !ok { 125 - return fmt.Errorf("unexpected recipe generator type: %T", generator) 126 - } 127 - 128 - if ingredient != "" { 129 - f := recipes.Filter(ingredient, []string{"*"}, false /*frozen*/) 130 - ings, err := g.GetIngredients(ctx, location, f, 0) 131 - if err != nil { 132 - return fmt.Errorf("failed to get ingredients: %w", err) 133 - } 134 - encoded, err := gotoon.Encode(ings) 135 - if err != nil { 136 - return fmt.Errorf("failed to encode ingredients to TOON: %w", err) 137 - } 138 - fmt.Println(encoded) 139 - return nil 140 - } 141 - 142 - ls, err := locations.New(ctx, cfg) 143 - if err != nil { 144 - return fmt.Errorf("failed to create location server: %w", err) 145 - } 146 - 147 - l, err := ls.GetLocationByID(ctx, location) // get details but ignore error 148 - if err != nil { 149 - return fmt.Errorf("could not get location details: %w", err) 150 - } 151 - 152 - p := recipes.DefaultParams(l, time.Now()) 153 - ingredients, err := g.GetStaples(ctx, p) 154 - if err != nil { 155 - return fmt.Errorf("failed to get staple ingredients: %w", err) 64 + if err := runServer(cfg, logcfg, addr); err != nil { 65 + log.Fatalf("server error: %v", err) 156 66 } 157 - log.Println("Staple Ingredients:") 158 - for _, ing := range ingredients { 159 - fmt.Printf("- %s\n", *ing.Description) 160 - } 161 - 162 - return nil 163 67 }
+1 -1
cmd/careme/static/tailwind.css
··· 1 1 /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ 2 - @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-extrabold:800;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:var(--brand-50);--color-brand-100:var(--brand-100);--color-brand-200:var(--brand-200);--color-brand-300:var(--brand-300);--color-brand-400:var(--brand-400);--color-brand-500:var(--brand-500);--color-brand-600:var(--brand-600);--color-brand-700:var(--brand-700)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.right-0{right:calc(var(--spacing)*0)}.z-10{z-index:10}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-4{height:calc(var(--spacing)*4)}.h-14{height:calc(var(--spacing)*14)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-14{width:calc(var(--spacing)*14)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-12>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*12)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*12)*calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-brand-100>:not(:last-child)){border-color:var(--color-brand-100)}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-brand-100{border-color:var(--color-brand-100)}.border-brand-200{border-color:var(--color-brand-200)}.border-brand-300{border-color:var(--color-brand-300)}.border-brand-400{border-color:var(--color-brand-400)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-red-500{border-color:var(--color-red-500)}.border-t-brand-600{border-top-color:var(--color-brand-600)}.bg-brand-50,.bg-brand-50\/60{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab,var(--color-brand-50)60%,transparent)}}.bg-brand-500{background-color:var(--color-brand-500)}.bg-brand-600{background-color:var(--color-brand-600)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab,var(--color-white)95%,transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-50{--tw-gradient-from:var(--color-brand-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.p-2{padding:calc(var(--spacing)*2)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-10{padding:calc(var(--spacing)*10)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-10{padding-block:calc(var(--spacing)*10)}.py-16{padding-block:calc(var(--spacing)*16)}.pt-8{padding-top:calc(var(--spacing)*8)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-5{padding-left:calc(var(--spacing)*5)}.text-center{text-align:center}.text-left{text-align:left}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\/dismiss\:border-red-700:is(:where(.peer\/dismiss):checked~*){border-color:var(--color-red-700)}.peer-checked\/dismiss\:bg-red-600:is(:where(.peer\/dismiss):checked~*){background-color:var(--color-red-600)}.peer-checked\/dismiss\:text-white:is(:where(.peer\/dismiss):checked~*){color:var(--color-white)}.peer-checked\/save\:border-emerald-700:is(:where(.peer\/save):checked~*){border-color:var(--color-emerald-700)}.peer-checked\/save\:bg-emerald-600:is(:where(.peer\/save):checked~*){background-color:var(--color-emerald-600)}.peer-checked\/save\:text-white:is(:where(.peer\/save):checked~*){color:var(--color-white)}@media (hover:hover){.hover\:bg-brand-50:hover{background-color:var(--color-brand-50)}.hover\:bg-brand-100:hover{background-color:var(--color-brand-100)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-brand-700:hover{background-color:var(--color-brand-700)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-600:hover{background-color:var(--color-emerald-600)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:text-brand-700:hover{color:var(--color-brand-700)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-brand-500:focus{border-color:var(--color-brand-500)}.focus\:bg-brand-50:focus{background-color:var(--color-brand-50)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-brand-400:focus{--tw-ring-color:var(--color-brand-400)}.focus\:ring-emerald-300:focus{--tw-ring-color:var(--color-emerald-300)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:w-48{width:calc(var(--spacing)*48)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes spin{to{transform:rotate(360deg)}} 2 + @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-extrabold:800;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand-50:var(--brand-50);--color-brand-100:var(--brand-100);--color-brand-200:var(--brand-200);--color-brand-300:var(--brand-300);--color-brand-400:var(--brand-400);--color-brand-500:var(--brand-500);--color-brand-600:var(--brand-600);--color-brand-700:var(--brand-700)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.right-0{right:calc(var(--spacing)*0)}.z-10{z-index:10}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-10{margin-top:calc(var(--spacing)*10)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-4{height:calc(var(--spacing)*4)}.h-14{height:calc(var(--spacing)*14)}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-14{width:calc(var(--spacing)*14)}.w-48{width:calc(var(--spacing)*48)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.flex-1{flex:1}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-12>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*12)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*12)*calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-brand-100>:not(:last-child)){border-color:var(--color-brand-100)}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-brand-100{border-color:var(--color-brand-100)}.border-brand-200{border-color:var(--color-brand-200)}.border-brand-300{border-color:var(--color-brand-300)}.border-brand-400{border-color:var(--color-brand-400)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-red-500{border-color:var(--color-red-500)}.border-t-brand-600{border-top-color:var(--color-brand-600)}.bg-brand-50,.bg-brand-50\/60{background-color:var(--color-brand-50)}@supports (color:color-mix(in lab, red, red)){.bg-brand-50\/60{background-color:color-mix(in oklab,var(--color-brand-50)60%,transparent)}}.bg-brand-500{background-color:var(--color-brand-500)}.bg-brand-600{background-color:var(--color-brand-600)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab,var(--color-white)95%,transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-brand-50{--tw-gradient-from:var(--color-brand-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.p-2{padding:calc(var(--spacing)*2)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-10{padding:calc(var(--spacing)*10)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-10{padding-block:calc(var(--spacing)*10)}.py-16{padding-block:calc(var(--spacing)*16)}.pt-8{padding-top:calc(var(--spacing)*8)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-5{padding-left:calc(var(--spacing)*5)}.text-center{text-align:center}.text-left{text-align:left}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\/dismiss\:border-red-700:is(:where(.peer\/dismiss):checked~*){border-color:var(--color-red-700)}.peer-checked\/dismiss\:bg-red-600:is(:where(.peer\/dismiss):checked~*){background-color:var(--color-red-600)}.peer-checked\/dismiss\:text-white:is(:where(.peer\/dismiss):checked~*){color:var(--color-white)}.peer-checked\/save\:border-emerald-700:is(:where(.peer\/save):checked~*){border-color:var(--color-emerald-700)}.peer-checked\/save\:bg-emerald-600:is(:where(.peer\/save):checked~*){background-color:var(--color-emerald-600)}.peer-checked\/save\:text-white:is(:where(.peer\/save):checked~*){color:var(--color-white)}@media (hover:hover){.hover\:bg-brand-50:hover{background-color:var(--color-brand-50)}.hover\:bg-brand-100:hover{background-color:var(--color-brand-100)}.hover\:bg-brand-600:hover{background-color:var(--color-brand-600)}.hover\:bg-brand-700:hover{background-color:var(--color-brand-700)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-600:hover{background-color:var(--color-emerald-600)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:text-brand-700:hover{color:var(--color-brand-700)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-brand-500:focus{border-color:var(--color-brand-500)}.focus\:bg-brand-50:focus{background-color:var(--color-brand-50)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-brand-400:focus{--tw-ring-color:var(--color-brand-400)}.focus\:ring-emerald-300:focus{--tw-ring-color:var(--color-emerald-300)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:w-48{width:calc(var(--spacing)*48)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes spin{to{transform:rotate(360deg)}}
+42 -35
cmd/careme/web.go
··· 1 1 package main 2 2 3 3 import ( 4 + "careme/internal/auth" 4 5 "careme/internal/cache" 5 6 "careme/internal/config" 6 7 "careme/internal/locations" ··· 19 20 "net/http" 20 21 "os" 21 22 "os/signal" 22 - "strings" 23 23 "syscall" 24 24 "time" 25 25 ) ··· 30 30 //go:embed static/tailwind.css 31 31 var tailwindCSS []byte 32 32 33 - const sessionDuration = 365 * 24 * time.Hour 34 - 35 33 func runServer(cfg *config.Config, logsinkCfg logsink.Config, addr string) error { 36 34 cache, err := cache.MakeCache() 37 35 if err != nil { 38 36 return fmt.Errorf("failed to create cache: %w", err) 39 37 } 40 38 39 + authClient, err := auth.NewClient(cfg.Clerk.SecretKey) 40 + if err != nil { 41 + return fmt.Errorf("failed to create clerk client: %w", err) 42 + } 43 + 41 44 userStorage := users.NewStorage(cache) 42 45 43 46 generator, err := recipes.NewGenerator(cfg, cache) ··· 59 62 } 60 63 locations.Register(locationserver, mux) 61 64 62 - userHandler := users.NewHandler(userStorage, locationserver) 65 + userHandler := users.NewHandler(userStorage, locationserver, authClient) 63 66 userHandler.Register(mux) 64 67 65 - recipeHandler := recipes.NewHandler(cfg, userStorage, generator, locationserver, cache) 68 + recipeHandler := recipes.NewHandler(cfg, userStorage, generator, locationserver, cache, authClient) 66 69 recipeHandler.Register(mux) 67 70 68 71 if logsinkCfg.Enabled() { ··· 75 78 76 79 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 77 80 ctx := r.Context() 78 - currentUser, err := users.FromRequest(r, userStorage) 81 + var currentUser *users.User 82 + clerkUserID, err := authClient.GetUserIDFromRequest(r) 79 83 if err != nil { 80 - if errors.Is(err, users.ErrNotFound) { 81 - users.ClearCookie(w) 82 - } else { 83 - slog.ErrorContext(ctx, "failed to load user from cookie", "error", err) 84 + if !errors.Is(err, auth.ErrNoSession) { 85 + slog.ErrorContext(ctx, "failed to get clerk user ID", "error", err) 86 + http.Error(w, "unable to load account", http.StatusInternalServerError) 87 + return 88 + } 89 + //no user is fine we'll just pass nil currentUser to template 90 + // just have two different templates? 91 + 92 + } else { 93 + currentUser, err = userStorage.FindOrCreateFromClerk(ctx, clerkUserID, authClient) 94 + if err != nil { 95 + slog.ErrorContext(ctx, "failed to get user by clerk ID", "clerk_user_id", clerkUserID, "error", err) 84 96 http.Error(w, "unable to load account", http.StatusInternalServerError) 85 97 return 86 98 } ··· 100 112 } 101 113 }) 102 114 103 - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { 104 - if r.Method != http.MethodPost { 105 - w.WriteHeader(http.StatusMethodNotAllowed) 115 + //TODO move signin/up/auth/establish/logout to auth package 116 + mux.HandleFunc("/sign-in", func(w http.ResponseWriter, r *http.Request) { 117 + http.Redirect(w, r, cfg.Clerk.Signin(), http.StatusSeeOther) 118 + }) 119 + mux.HandleFunc("/sign-up", func(w http.ResponseWriter, r *http.Request) { 120 + http.Redirect(w, r, cfg.Clerk.Signup(), http.StatusSeeOther) 121 + }) 122 + mux.HandleFunc("/auth/establish", func(w http.ResponseWriter, r *http.Request) { 123 + if cfg.Clerk.PublishableKey == "" { 124 + http.Error(w, "clerk publishable key missing", http.StatusInternalServerError) 106 125 return 107 126 } 108 - if err := r.ParseForm(); err != nil { 109 - http.Error(w, "invalid form submission", http.StatusBadRequest) 110 - return 111 - } 112 - email := strings.TrimSpace(r.FormValue("email")) 113 - if email == "" { 114 - http.Error(w, "email is required", http.StatusBadRequest) 115 - return 127 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 128 + data := struct { 129 + PublishableKey string 130 + }{ 131 + PublishableKey: cfg.Clerk.PublishableKey, 116 132 } 117 - user, err := userStorage.FindOrCreateByEmail(email) 118 - if err != nil { 119 - slog.ErrorContext(r.Context(), "failed to find or create user", "error", err) 120 - http.Error(w, fmt.Sprintf("unable to sign in: %v", err), http.StatusInternalServerError) 121 - return 133 + if err := templates.AuthEstablish.Execute(w, data); err != nil { 134 + slog.ErrorContext(r.Context(), "auth establish template execute error", "error", err) 135 + http.Error(w, "template error", http.StatusInternalServerError) 122 136 } 123 - users.SetCookie(w, user.ID, sessionDuration) 124 - http.Redirect(w, r, "/", http.StatusSeeOther) 125 137 }) 126 138 127 139 mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { 128 - if r.Method != http.MethodPost { 129 - w.WriteHeader(http.StatusMethodNotAllowed) 130 - return 131 - } 132 - users.ClearCookie(w) 133 - http.Redirect(w, r, "/", http.StatusSeeOther) 140 + authClient.Logout(w, r) 134 141 }) 135 142 136 143 mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { ··· 149 156 150 157 server := &http.Server{ 151 158 Addr: addr, 152 - Handler: WithMiddleware(mux), 159 + Handler: authClient.WithAuthHTTP(WithMiddleware(mux)), 153 160 } 154 161 155 162 // Channel to listen for errors coming from the server
+5 -48
cmd/careme/web_e2e_test.go
··· 12 12 "testing" 13 13 "time" 14 14 15 + "careme/internal/auth" 15 16 "careme/internal/cache" 16 17 "careme/internal/config" 17 18 "careme/internal/locations" ··· 30 31 // Step 1: query locations for 90005 and ensure it returns a /recipes?location link. 31 32 locationsBody := mustGetBody(t, client, srv.URL+"/locations?zip=90005") 32 33 locationID := extractLocationID(t, locationsBody) 33 - 34 - // Log in to avoid redirect back to home when hitting /recipes. 35 - login(t, client, srv.URL, "test@example.com") 36 34 37 35 // Step 2: go to /recipes?location=<id> and follow redirects until recipes render. 38 36 initialRecipesURL := srv.URL + "/recipes?location=" + url.QueryEscape(locationID) ··· 92 90 t.Fatalf("failed to create location server: %v", err) 93 91 } 94 92 93 + mockAuth := auth.Mock(cfg) 94 + 95 95 mux := http.NewServeMux() 96 96 locations.Register(locationServer, mux) 97 - users.NewHandler(userStorage, locationServer).Register(mux) 98 - recipes.NewHandler(cfg, userStorage, generator, locationServer, cacheStore).Register(mux) 99 - 100 - //todo find a better way to mock this or move it to web.go? 101 - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { 102 - if r.Method != http.MethodPost { 103 - w.WriteHeader(http.StatusMethodNotAllowed) 104 - return 105 - } 106 - if err := r.ParseForm(); err != nil { 107 - http.Error(w, "invalid form submission", http.StatusBadRequest) 108 - return 109 - } 110 - email := strings.TrimSpace(r.FormValue("email")) 111 - if email == "" { 112 - http.Error(w, "email is required", http.StatusBadRequest) 113 - return 114 - } 115 - user, err := userStorage.FindOrCreateByEmail(email) 116 - if err != nil { 117 - http.Error(w, "unable to sign in", http.StatusInternalServerError) 118 - return 119 - } 120 - users.SetCookie(w, user.ID, sessionDuration) 121 - w.WriteHeader(http.StatusOK) 122 - }) 97 + users.NewHandler(userStorage, locationServer, mockAuth).Register(mux) 98 + recipes.NewHandler(cfg, userStorage, generator, locationServer, cacheStore, mockAuth).Register(mux) 123 99 124 100 return httptest.NewServer(WithMiddleware(mux)) 125 101 } ··· 132 108 } 133 109 return &http.Client{ 134 110 Jar: jar, 135 - } 136 - } 137 - 138 - func login(t *testing.T, client *http.Client, baseURL, email string) { 139 - t.Helper() 140 - form := url.Values{} 141 - form.Set("email", email) 142 - resp, err := client.PostForm(baseURL+"/login", form) 143 - if err != nil { 144 - t.Fatalf("login request failed: %v", err) 145 - } 146 - defer func() { 147 - if err := resp.Body.Close(); err != nil { 148 - t.Fatalf("failed to close login response body: %v", err) 149 - } 150 - }() 151 - if resp.StatusCode != http.StatusOK { 152 - body := readAll(t, resp.Body) 153 - t.Fatalf("expected login 200, got %d: %s", resp.StatusCode, body) 154 111 } 155 112 } 156 113
+2 -2
deploy/deploy.yaml
··· 5 5 labels: 6 6 app: careme 7 7 spec: 8 - replicas: 1 8 + replicas: 2 9 9 selector: 10 10 matchLabels: 11 11 app: careme ··· 28 28 name: http 29 29 envFrom: 30 30 - secretRef: 31 - name: careme-secrets 31 + name: careme-secrets3 32 32 env: 33 33 - name: CLARITY_PROJECT_ID 34 34 value: "td2gxd3sq9"
+2 -2
getsecret.sh
··· 9 9 exit 1 10 10 fi 11 11 12 - SECRET_NAME="${1:-careme-secrets}" 12 + SECRET_NAME="${1:-careme-secrets3}" 13 13 NAMESPACE="${2:-careme}" 14 14 OUTPUT_FILE="${3:-.env}" 15 15 ··· 23 23 | "\(.key)=\(.value | @base64d)" 24 24 ' > "${OUTPUT_FILE}" 25 25 26 - echo "Wrote environment variables from secret '${SECRET_NAME}' (ns: ${NAMESPACE}) to ${OUTPUT_FILE}" 26 + echo "Wrote environment variables from secret '${SECRET_NAME}' (ns: ${NAMESPACE}) to ${OUTPUT_FILE}"
+3
go.mod
··· 22 22 github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect 23 23 github.com/bahlo/generic-list-go v0.2.0 // indirect 24 24 github.com/buger/jsonparser v1.1.1 // indirect 25 + github.com/clerk/clerk-sdk-go/v2 v2.5.0 // indirect 26 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 25 27 github.com/samber/slog-common v0.19.0 // indirect 26 28 github.com/stretchr/testify v1.11.1 // indirect 27 29 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 30 + golang.org/x/crypto v0.40.0 // indirect 28 31 golang.org/x/sync v0.16.0 // indirect 29 32 ) 30 33
+30
go.sum
··· 23 23 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 24 24 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 25 25 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 26 + github.com/clerk/clerk-sdk-go/v2 v2.5.0 h1:+haviGll3gfUNE1Y7JwGQa7vICz7RhA9dmyT5eET1Rc= 27 + github.com/clerk/clerk-sdk-go/v2 v2.5.0/go.mod h1:VlJ9eDtVdZhugRPbguGJNMVwA7ToFOsXvjtkn20MKjE= 26 28 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 29 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 30 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= ··· 34 36 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 35 37 github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= 36 38 github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= 39 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 40 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 37 41 github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 38 42 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 39 43 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= ··· 56 60 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 57 61 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 58 62 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 63 + github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 59 64 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 60 65 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 61 66 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 135 140 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 136 141 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 137 142 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 143 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 138 144 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 139 145 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 140 146 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= ··· 154 160 github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 155 161 github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 156 162 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 163 + github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 157 164 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 158 165 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 159 166 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 167 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 168 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 160 169 golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 161 170 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 162 171 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 172 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 173 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 163 174 golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 164 175 golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 165 176 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= ··· 167 178 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 168 179 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 169 180 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 181 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 170 182 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 171 183 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 184 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 185 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 186 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 172 187 golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 173 188 golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 174 189 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 175 190 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 176 191 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 192 + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 193 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 177 194 golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 178 195 golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 179 196 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= ··· 190 207 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 191 208 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 192 209 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 210 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 211 + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 212 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 213 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 214 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 193 215 golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 194 216 golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 195 217 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 196 218 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 219 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 220 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 221 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 197 222 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 198 223 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 199 224 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 200 225 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 226 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 227 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 228 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 201 229 golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 202 230 golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 203 231 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 204 232 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 205 233 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 234 + golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 235 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 206 236 golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= 207 237 golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 208 238 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+171
internal/auth/clerk.go
··· 1 + package auth 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "time" 10 + 11 + "github.com/clerk/clerk-sdk-go/v2" 12 + clerkhttp "github.com/clerk/clerk-sdk-go/v2/http" 13 + "github.com/clerk/clerk-sdk-go/v2/session" 14 + "github.com/clerk/clerk-sdk-go/v2/user" 15 + ) 16 + 17 + var ( 18 + ErrNoSession = errors.New("no valid session found") 19 + ) 20 + 21 + // Client wraps Clerk SDK functionality 22 + // todo private 23 + type clerkClient struct { 24 + secretKey string 25 + } 26 + 27 + type AuthClient interface { 28 + GetUserEmail(ctx context.Context, clerkUserID string) (string, error) 29 + GetUserIDFromRequest(r *http.Request) (string, error) 30 + } 31 + 32 + var _ AuthClient = (*clerkClient)(nil) 33 + 34 + // NewClient creates a new Clerk client wrapper 35 + func NewClient(secretKey string) (*clerkClient, error) { 36 + if secretKey == "" { 37 + return nil, fmt.Errorf("clerk secret key is required") 38 + } 39 + 40 + // Set the global Clerk secret key 41 + //TODO use a local client instead? GLOBALS BAD 42 + clerk.SetKey(secretKey) 43 + 44 + return &clerkClient{ 45 + secretKey: secretKey, 46 + }, nil 47 + } 48 + 49 + func (c *clerkClient) GetUserEmail(ctx context.Context, clerkUserID string) (string, error) { 50 + clerkUser, err := user.Get(ctx, clerkUserID) 51 + if err != nil { 52 + return "", fmt.Errorf("failed to fetch clerk user: %w", err) 53 + } 54 + 55 + // Get primary email from Clerk user. 56 + // do we need to rync this ever? 57 + var primaryEmail string 58 + for _, emailAddr := range clerkUser.EmailAddresses { 59 + if clerkUser.PrimaryEmailAddressID != nil && 60 + emailAddr.ID == *clerkUser.PrimaryEmailAddressID { 61 + primaryEmail = emailAddr.EmailAddress 62 + break 63 + } 64 + } 65 + 66 + if primaryEmail == "" { 67 + return "", fmt.Errorf("no primary email found for clerk user %s", clerkUserID) 68 + } 69 + 70 + return primaryEmail, nil 71 + } 72 + 73 + func (c *clerkClient) GetUserIDFromRequest(r *http.Request) (string, error) { 74 + sessionClaims, ok := clerk.SessionClaimsFromContext(r.Context()) 75 + if !ok || sessionClaims == nil { 76 + return "", ErrNoSession 77 + } 78 + return sessionClaims.Subject, nil 79 + } 80 + 81 + func (c *clerkClient) FromRequest(ctx context.Context, req *http.Request) (string, error) { 82 + clerkUserID, err := c.GetUserIDFromRequest(req) 83 + if err != nil { 84 + return "", err 85 + } 86 + slog.InfoContext(ctx, "found clerk user ID", "clerk_user_id", clerkUserID) 87 + return clerkUserID, nil 88 + } 89 + 90 + // WithClerkHTTP wraps the http.Handler with Clerk's authentication middleware 91 + func (c *clerkClient) WithAuthHTTP(handler http.Handler) http.Handler { 92 + 93 + purgeAndRedirect := clerkhttp.AuthorizationFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 + slog.Info("authorization failure, purging cookies and redirecting") 95 + // Clear any existing Clerk cookies by setting them to expired 96 + clearCookie(w, "__session") 97 + http.Redirect(w, r, r.RequestURI, http.StatusFound) 98 + })) 99 + 100 + useSessionCookie := clerkhttp.AuthorizationJWTExtractor(func(r *http.Request) string { 101 + 102 + if c, err := r.Cookie("__session"); err == nil { 103 + return c.Value 104 + } 105 + return "" 106 + }) 107 + 108 + wrapped := clerkhttp.WithHeaderAuthorization(purgeAndRedirect, useSessionCookie)(handler) 109 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 110 + wrapped.ServeHTTP(w, r) 111 + }) 112 + } 113 + 114 + func (c *clerkClient) Logout(w http.ResponseWriter, r *http.Request) { 115 + claims, ok := clerk.SessionClaimsFromContext(r.Context()) 116 + if ok && claims.SessionID != "" { 117 + // Revoke the active Clerk session (sid claim). 118 + _, _ = session.Revoke(r.Context(), &session.RevokeParams{ID: claims.SessionID}) 119 + } 120 + 121 + // Clear app-domain cookies that can re-bootstrap auth. 122 + clearCookie(w, "__session") 123 + clearCookie(w, "__clerk_db_jwt") // common in dev flows 124 + clearCookie(w, "__client") // if present in your setup 125 + 126 + // Redirect to a logged-out page in your app. 127 + http.Redirect(w, r, "/", http.StatusFound) 128 + } 129 + 130 + func clearCookie(w http.ResponseWriter, name string) { 131 + http.SetCookie(w, &http.Cookie{ 132 + Name: name, 133 + Value: "", 134 + Path: "/", 135 + Expires: time.Unix(0, 0), 136 + MaxAge: -1, 137 + HttpOnly: true, 138 + Secure: true, // keep this true in prod 139 + SameSite: http.SameSiteLaxMode, 140 + }) 141 + } 142 + 143 + /* Toss this in if you're confused :) 144 + func debugAuth(next http.Handler) http.Handler { 145 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 146 + q := r.URL.Query() 147 + hasDB := q.Has("__clerk_db_jwt") 148 + 149 + authz := r.Header.Get("Authorization") 150 + hasAuthz := authz != "" 151 + 152 + // list cookie names only (don’t log values) 153 + cookieNames := []string{} 154 + for _, c := range r.Cookies() { 155 + cookieNames = append(cookieNames, c.Name) 156 + } 157 + 158 + log.Printf("auth-debug path=%s host=%s xf_proto=%q xf_host=%q hasAuthz=%t has__clerk_db_jwt=%t cookies=%v", 159 + r.URL.Path, 160 + r.Host, 161 + r.Header.Get("X-Forwarded-Proto"), 162 + r.Header.Get("X-Forwarded-Host"), 163 + hasAuthz, 164 + hasDB, 165 + cookieNames, 166 + ) 167 + 168 + next.ServeHTTP(w, r) 169 + 170 + }) 171 + }*/
+34
internal/auth/mock.go
··· 1 + package auth 2 + 3 + import ( 4 + "careme/internal/config" 5 + "context" 6 + "net/http" 7 + ) 8 + 9 + // Client wraps Clerk SDK functionality 10 + type mockClient struct { 11 + email string 12 + } 13 + 14 + var _ AuthClient = (*mockClient)(nil) 15 + 16 + // NewClient creates a new Clerk client wrapper 17 + func Mock(cfg *config.Config) AuthClient { 18 + email := cfg.Mocks.Email 19 + if email == "" { 20 + email = "you@careme.cooking" 21 + } 22 + 23 + return &mockClient{ 24 + email: email, 25 + } 26 + } 27 + 28 + func (c *mockClient) GetUserEmail(ctx context.Context, clerkUserID string) (string, error) { 29 + return c.email, nil 30 + } 31 + 32 + func (c *mockClient) GetUserIDFromRequest(r *http.Request) (string, error) { 33 + return "mock-clerk-user-id", nil 34 + }
+45
internal/config/config.go
··· 3 3 import ( 4 4 "fmt" 5 5 "os" 6 + "strings" 6 7 ) 7 8 8 9 type Config struct { 9 10 AI AIConfig `json:"ai"` 10 11 Kroger KrogerConfig `json:"kroger"` 11 12 Mocks MockConfig `json:"mocks"` 13 + Clerk ClerkConfig `json:"clerk"` 12 14 } 13 15 14 16 type AIConfig struct { ··· 22 24 23 25 type MockConfig struct { 24 26 Enable bool 27 + Email string 28 + } 29 + 30 + type ClerkConfig struct { 31 + SecretKey string 32 + PublishableKey string 33 + Domain string 34 + Prod bool 35 + } 36 + 37 + func (c *ClerkConfig) IsEnabled() bool { 38 + return c.SecretKey != "" && c.Domain != "" && c.PublishableKey != "" 39 + } 40 + 41 + var locahostredirect = "?redirect_url=http://localhost:8080/auth/establish" 42 + 43 + func (c *ClerkConfig) Signin() string { 44 + url := fmt.Sprintf("https://%s/sign-in", c.Domain) 45 + if !c.Prod { 46 + url += locahostredirect 47 + } 48 + return url 49 + } 50 + 51 + func (c *ClerkConfig) Signup() string { 52 + url := fmt.Sprintf("https://%s/sign-up", c.Domain) 53 + if !c.Prod { 54 + url += locahostredirect 55 + } 56 + return url 25 57 } 26 58 27 59 func Load() (*Config, error) { ··· 35 67 }, 36 68 Mocks: MockConfig{ 37 69 Enable: os.Getenv("ENABLE_MOCKS") != "", // strconv 70 + Email: os.Getenv("MOCK_USER_EMAIL"), 38 71 }, 72 + Clerk: ClerkConfig{ 73 + SecretKey: os.Getenv("CLERK_SECRET_KEY"), 74 + PublishableKey: os.Getenv("CLERK_PUBLISHABLE_KEY"), 75 + Domain: os.Getenv("CLERK_DOMAIN"), 76 + }, 77 + } 78 + if strings.HasSuffix(config.Clerk.Domain, "careme.cooking") { 79 + config.Clerk.Prod = true 39 80 } 40 81 41 82 return config, validate(config) ··· 45 86 if cfg.Mocks.Enable { 46 87 return nil 47 88 } 89 + if !cfg.Clerk.IsEnabled() { 90 + return fmt.Errorf("clerk configuration must be set") 91 + } 92 + 48 93 if cfg.Kroger.ClientID == "" || cfg.Kroger.ClientSecret == "" { 49 94 return fmt.Errorf("kroger client ID and secret must be set") 50 95 }
+26 -11
internal/recipes/server.go
··· 2 2 3 3 import ( 4 4 "careme/internal/ai" 5 + "careme/internal/auth" 5 6 "careme/internal/cache" 6 7 "careme/internal/config" 7 8 "careme/internal/kroger" ··· 39 40 generator generator 40 41 locServer locServer 41 42 wg sync.WaitGroup 43 + clerk auth.AuthClient 42 44 } 43 45 44 46 // NewHandler returns an http.Handler serving the recipe endpoints under /recipes. 45 47 // cache must be connected to generator or this will not work. Should we enfroce that by getting cache from generator? 46 - func NewHandler(cfg *config.Config, storage *users.Storage, generator generator, locServer locServer, c cache.Cache) *server { 48 + func NewHandler(cfg *config.Config, storage *users.Storage, generator generator, locServer locServer, c cache.Cache, clerkClient auth.AuthClient) *server { 47 49 return &server{ 48 50 recipeio: recipeio{Cache: c}, 49 51 cache: c, ··· 51 53 storage: storage, 52 54 generator: generator, 53 55 locServer: locServer, 56 + clerk: clerkClient, 54 57 } 55 58 } 56 59 ··· 121 124 http.Error(w, "recipe not found or expired", http.StatusNotFound) 122 125 return 123 126 } 124 - currentUser, err := users.FromRequest(r, s.storage) 127 + clerkUserID, err := s.clerk.GetUserIDFromRequest(r) 125 128 if err != nil { 126 - if errors.Is(err, users.ErrNotFound) { 127 - users.ClearCookie(w) 128 - http.Redirect(w, r, "/", http.StatusSeeOther) 129 + if !errors.Is(err, auth.ErrNoSession) { 130 + slog.ErrorContext(ctx, "failed to get clerk user ID", "error", err) 131 + http.Error(w, "unable to load account", http.StatusInternalServerError) 129 132 return 130 133 } 131 - slog.ErrorContext(ctx, "failed to load user for recipes", "error", err) 134 + http.Redirect(w, r, "/", http.StatusSeeOther) 135 + return 136 + } 137 + currentUser, err := s.storage.FindOrCreateFromClerk(ctx, clerkUserID, s.clerk) 138 + if err != nil { 139 + slog.ErrorContext(ctx, "failed to get user by clerk ID", "clerk_user_id", clerkUserID, "error", err) 132 140 http.Error(w, "unable to load account", http.StatusInternalServerError) 133 141 return 134 142 } ··· 198 206 199 207 hash := p.Hash() 200 208 201 - currentUser, err := users.FromRequest(r, s.storage) 209 + clerkUserID, err := s.clerk.GetUserIDFromRequest(r) 202 210 if err != nil { 203 - if errors.Is(err, users.ErrNotFound) { 204 - users.ClearCookie(w) 205 - http.Redirect(w, r, "/", http.StatusSeeOther) 211 + if !errors.Is(err, auth.ErrNoSession) { 212 + slog.ErrorContext(ctx, "failed to get clerk user ID", "error", err) 213 + http.Error(w, "unable to load account", http.StatusInternalServerError) 206 214 return 207 215 } 208 - slog.ErrorContext(ctx, "failed to load user for recipes", "error", err) 216 + slog.InfoContext(ctx, "failed got no sesion from request", "error", err, "url", r.URL.String()) 217 + http.Redirect(w, r, "/", http.StatusSeeOther) 218 + return 219 + } 220 + 221 + currentUser, err := s.storage.FindOrCreateFromClerk(ctx, clerkUserID, s.clerk) 222 + if err != nil { 223 + slog.ErrorContext(ctx, "failed to get user by clerk ID", "clerk_user_id", clerkUserID, "error", err) 209 224 http.Error(w, "unable to load account", http.StatusInternalServerError) 210 225 return 211 226 }
+23
internal/templates/auth_establish.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <title>Signing In</title> 7 + </head> 8 + <body> 9 + <script 10 + crossorigin="anonymous" 11 + data-clerk-publishable-key="{{.PublishableKey}}" 12 + src="https://cdn.jsdelivr.net/npm/@clerk/clerk-js@latest/dist/clerk.browser.js"></script> 13 + <script> 14 + Clerk.load().then(() => { 15 + // Just loading Clerk on your domain is often enough to finalize cookies in dev. 16 + const url = new URL(location.href); 17 + url.searchParams.delete("__clerk_db_jwt"); 18 + history.replaceState({}, "", url.toString()); 19 + location.replace("/"); 20 + }); 21 + </script> 22 + </body> 23 + </html>
+23
internal/templates/clerk_refresh.html
··· 1 + {{if (ClerkEnabled)}} 2 + <script 3 + async 4 + crossorigin="anonymous" 5 + data-clerk-publishable-key="{{ClerkPublishableKey}}" 6 + src="https://cdn.jsdelivr.net/npm/@clerk/clerk-js@latest/dist/clerk.browser.js"> 7 + </script> 8 + 9 + <script> 10 + // Minimal: initialize Clerk so it can keep session tokens refreshed 11 + (async () => { 12 + // wait until Clerk is present 13 + while (!window.Clerk?.load) await new Promise(r => setTimeout(r, 10)); 14 + await Clerk.load(); 15 + 16 + // Optional: if you want to be extra defensive, force-refresh occasionally 17 + // (Clerk docs describe forcing refresh via skipCache on getToken) 18 + // setInterval(async () => { 19 + // if (Clerk.session) await Clerk.session.getToken({ skipCache: true }); 20 + // }, 45_000); 21 + })(); 22 + </script> 23 + {{end}}
+14 -12
internal/templates/home.html
··· 91 91 </ol> 92 92 93 93 <!-- Signed-out state --> 94 - <form method="POST" action="/login" class="mt-6"> 95 - <label for="email" class="block text-sm font-medium text-gray-700"> 96 - Enter your email to continue: 97 - </label> 98 - <div class="mt-2 flex flex-col gap-3 sm:flex-row"> 99 - <input id="email" name="email" type="email" required placeholder="you@example.com" 100 - class="w-full max-w-sm rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 placeholder-gray-400 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400" /> 101 - <button type="submit" 102 - class="inline-flex items-center justify-center rounded-lg bg-brand-600 px-4 py-2.5 font-semibold text-white shadow-md transition hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 103 - Continue 104 - </button> 94 + <div class="mt-6"> 95 + <p class="block text-sm font-medium text-gray-700 mb-3"> 96 + Sign in or create an account to continue: 97 + </p> 98 + <div class="flex flex-col gap-3 sm:flex-row"> 99 + <a href="/sign-in" 100 + class="inline-flex items-center justify-center rounded-lg bg-brand-600 px-4 py-2.5 font-semibold text-white shadow-md transition hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 101 + Sign In 102 + </a> 103 + <a href="/sign-up" 104 + class="inline-flex items-center justify-center rounded-lg border border-brand-300 bg-white px-4 py-2.5 font-semibold text-brand-700 shadow-sm transition hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 105 + Sign Up 106 + </a> 105 107 </div> 106 - </form> 107 108 {{end}} 108 109 </div> 109 110 </div> ··· 114 115 </p> 115 116 </section> 116 117 </main> 118 + {{template "clerk_refresh.html" .}} 117 119 </body> 118 120 </html>
+1 -1
internal/templates/locations.html
··· 44 44 </section> 45 45 </main> 46 46 </body> 47 - </html> 47 + </html>
+1
internal/templates/recipe.html
··· 85 85 <p class="text-center text-sm text-gray-500">Generated by Careme.</p> 86 86 </section> 87 87 </main> 88 + {{template "clerk_refresh.html" .}} 88 89 </body> 89 90 </html>
+1
internal/templates/shoppinglist.html
··· 232 232 }); 233 233 } 234 234 </script> 235 + {{template "clerk_refresh.html" .}} 235 236 </body> 236 237 </html>
+19 -1
internal/templates/templates.go
··· 11 11 12 12 var Home, 13 13 Spin, 14 + AuthEstablish, 14 15 User, 15 16 ShoppingList, 16 17 Recipe, ··· 18 19 Mail *template.Template 19 20 20 21 func init() { 21 - tmpls, err := template.ParseFS(htmlFiles, "*.html") 22 + clerkPublishableKey = os.Getenv("CLERK_PUBLISHABLE_KEY") 23 + funcs := template.FuncMap{ 24 + "ClerkEnabled": ClerkEnabled, 25 + "ClerkPublishableKey": ClerkPublishableKey, 26 + } 27 + tmpls, err := template.New("all").Funcs(funcs).ParseFS(htmlFiles, "*.html") 22 28 if err != nil { 23 29 panic(err.Error()) 24 30 } 25 31 Home = ensure(tmpls, "home.html") 26 32 Spin = ensure(tmpls, "spinner.html") 33 + AuthEstablish = ensure(tmpls, "auth_establish.html") 27 34 User = ensure(tmpls, "user.html") 28 35 ShoppingList = ensure(tmpls, "shoppinglist.html") 29 36 Recipe = ensure(tmpls, "recipe.html") ··· 47 54 } 48 55 49 56 var clarityproject string 57 + var clerkPublishableKey string 50 58 51 59 // ClarityScript generates the Microsoft Clarity tracking script HTML 52 60 func ClarityScript() template.HTML { ··· 64 72 65 73 return template.HTML(script) 66 74 } 75 + 76 + // ClerkEnabled reports whether Clerk is configured for templates. 77 + func ClerkEnabled() bool { 78 + return clerkPublishableKey != "" 79 + } 80 + 81 + // ClerkPublishableKey returns the Clerk publishable key for templates. 82 + func ClerkPublishableKey() string { 83 + return clerkPublishableKey 84 + }
+1
internal/templates/user.html
··· 139 139 <p class="mt-6 text-center text-sm text-gray-500">Need help? Reach out from the home page.</p> 140 140 </section> 141 141 </main> 142 + {{template "clerk_refresh.html" .}} 142 143 </body> 143 144 </html>
-52
internal/users/http.go
··· 1 - package users 2 - 3 - import ( 4 - "errors" 5 - "net/http" 6 - "time" 7 - ) 8 - 9 - // SetCookie stores the user identifier in the browser for the given duration. 10 - func SetCookie(w http.ResponseWriter, userID string, duration time.Duration) { 11 - http.SetCookie(w, &http.Cookie{ 12 - Name: CookieName, 13 - Value: userID, 14 - Path: "/", 15 - HttpOnly: true, 16 - SameSite: http.SameSiteLaxMode, 17 - Expires: time.Now().Add(duration), 18 - MaxAge: int(duration / time.Second), 19 - }) 20 - } 21 - 22 - // ClearCookie removes the stored user identifier from the browser. 23 - func ClearCookie(w http.ResponseWriter) { 24 - http.SetCookie(w, &http.Cookie{ 25 - Name: CookieName, 26 - Value: "", 27 - Path: "/", 28 - HttpOnly: true, 29 - SameSite: http.SameSiteLaxMode, 30 - MaxAge: -1, 31 - Expires: time.Unix(0, 0), 32 - }) 33 - } 34 - 35 - // FromRequest extracts the current user from the incoming request cookie. 36 - func FromRequest(r *http.Request, store *Storage) (*User, error) { 37 - cookie, err := r.Cookie(CookieName) 38 - if err != nil { 39 - if errors.Is(err, http.ErrNoCookie) { 40 - return nil, ErrNotFound 41 - } 42 - return nil, err 43 - } 44 - if cookie.Value == "" { 45 - return nil, ErrNotFound 46 - } 47 - user, err := store.GetByID(cookie.Value) 48 - if err != nil { 49 - return nil, err 50 - } 51 - return user, nil 52 - }
+29 -15
internal/users/server.go
··· 1 1 package users 2 2 3 3 import ( 4 + "careme/internal/auth" 4 5 "careme/internal/locations" 5 6 "careme/internal/seasons" 6 7 "careme/internal/templates" ··· 19 20 20 21 type server struct { 21 22 storage *Storage 22 - userTmpl *template.Template // just remove or is htis useful? 23 + userTmpl *template.Template // just remove or is this useful? 23 24 locGetter locationGetter 25 + clerk auth.AuthClient // make an interface 24 26 } 25 27 26 28 // NewHandler returns an http.Handler that serves the user related routes under /user. 27 - func NewHandler(storage *Storage, locGetter locationGetter) *server { 29 + func NewHandler(storage *Storage, locGetter locationGetter, clerkClient auth.AuthClient) *server { 28 30 return &server{ 29 31 storage: storage, 30 32 userTmpl: templates.User, 31 33 locGetter: locGetter, 34 + clerk: clerkClient, 32 35 } 33 36 } 34 37 ··· 37 40 mux.HandleFunc("POST /user/recipes", s.handleUserRecipes) 38 41 } 39 42 43 + // used on user page to manaully save recipes 40 44 func (s *server) handleUserRecipes(w http.ResponseWriter, r *http.Request) { 41 45 ctx := r.Context() 42 - currentUser, err := FromRequest(r, s.storage) 46 + 47 + clerkUserID, err := s.clerk.GetUserIDFromRequest(r) 43 48 if err != nil { 44 - slog.ErrorContext(ctx, "failed to load user for user page", "error", err) 45 - http.Error(w, "unable to load account", http.StatusInternalServerError) 49 + if !errors.Is(err, auth.ErrNoSession) { 50 + slog.ErrorContext(ctx, "failed to get clerk user ID", "error", err) 51 + http.Error(w, "unable to load account", http.StatusInternalServerError) 52 + return 53 + } 54 + http.Redirect(w, r, "/", http.StatusSeeOther) 46 55 return 47 56 } 48 - if currentUser == nil { 49 - http.Redirect(w, r, "/user", http.StatusSeeOther) 57 + slog.InfoContext(ctx, "found clerk user ID", "clerk_user_id", clerkUserID) 58 + currentUser, err := s.storage.FindOrCreateFromClerk(ctx, clerkUserID, s.clerk) 59 + if err != nil { 60 + slog.ErrorContext(ctx, "failed to get user by clerk ID", "clerk_user_id", clerkUserID, "error", err) 61 + http.Error(w, "unable to load account", http.StatusInternalServerError) 50 62 return 51 63 } 52 64 ··· 87 99 return 88 100 } 89 101 ctx := r.Context() 90 - currentUser, err := FromRequest(r, s.storage) 102 + clerkUserID, err := s.clerk.GetUserIDFromRequest(r) 91 103 if err != nil { 92 - if errors.Is(err, ErrNotFound) { 93 - ClearCookie(w) 94 - http.Redirect(w, r, "/", http.StatusSeeOther) 104 + if !errors.Is(err, auth.ErrNoSession) { 105 + slog.ErrorContext(ctx, "failed to get clerk user ID", "error", err) 106 + http.Error(w, "unable to load account", http.StatusInternalServerError) 95 107 return 96 108 } 97 - slog.ErrorContext(ctx, "failed to load user for user page", "error", err) 98 - http.Error(w, "unable to load account", http.StatusInternalServerError) 109 + http.Redirect(w, r, "/", http.StatusSeeOther) 99 110 return 100 111 } 101 - if currentUser == nil { 102 - http.Redirect(w, r, "/", http.StatusSeeOther) 112 + 113 + currentUser, err := s.storage.FindOrCreateFromClerk(ctx, clerkUserID, s.clerk) 114 + if err != nil { 115 + slog.ErrorContext(ctx, "failed to get user by clerk ID", "clerk_user_id", clerkUserID, "error", err) 116 + http.Error(w, "unable to load account", http.StatusInternalServerError) 103 117 return 104 118 } 105 119
+15 -6
internal/users/storage.go
··· 13 13 "strconv" 14 14 "strings" 15 15 "time" 16 - 17 - "github.com/google/uuid" 18 16 ) 19 17 20 18 var daysOfWeek = [...]string{ ··· 155 153 return s.GetByID(string(data)) 156 154 } 157 155 158 - func (s *Storage) FindOrCreateByEmail(email string) (*User, error) { 159 - user, err := s.GetByEmail(email) 156 + type emailFetcher interface { 157 + GetUserEmail(ctx context.Context, userID string) (string, error) 158 + } 159 + 160 + // interface for clerk client 161 + func (s *Storage) FindOrCreateFromClerk(ctx context.Context, clerkUserID string, emailFetcher emailFetcher) (*User, error) { 162 + user, err := s.GetByID(clerkUserID) 160 163 if err == nil { 161 164 return user, nil 162 165 } ··· 165 168 return nil, err 166 169 } 167 170 171 + primaryEmail, err := emailFetcher.GetUserEmail(ctx, clerkUserID) 172 + if err != nil { 173 + return nil, fmt.Errorf("failed to fetch user email from clerk: %w", err) 174 + } 175 + 168 176 newUser := User{ 169 - ID: uuid.New().String(), 170 - Email: []string{normalizeEmail(email)}, 177 + ID: clerkUserID, //do we need this o be independent for housholds? 178 + Email: []string{normalizeEmail(primaryEmail)}, 171 179 CreatedAt: time.Now(), 172 180 ShoppingDay: time.Saturday.String(), 173 181 } ··· 177 185 if err := s.cache.Put(context.TODO(), emailPrefix+newUser.Email[0], newUser.ID, cache.Unconditional()); err != nil { 178 186 return nil, fmt.Errorf("failed to index new user by email: %w", err) 179 187 } 188 + slog.InfoContext(ctx, "created new user", "id", clerkUserID, "email", primaryEmail) 180 189 return &newUser, nil 181 190 } 182 191