diff --git a/pkg/admin/admin.go b/pkg/admin/admin.go index 54a4845..11bc4ac 100644 --- a/pkg/admin/admin.go +++ b/pkg/admin/admin.go @@ -46,6 +46,8 @@ func Routes(r chi.Router) { } r.Get("/", GetAdmin) + r.Get("/users", GetUsers) + r.Delete("/users/{userID}", DeleteUser) r.Get("/documents/{id}", GetDocument) r.Get("/documents/{id}/ai", GetDocumentAI) r.Get("/documents/{id}/ai/{key}", ShowLog) diff --git a/pkg/admin/static/admin.css b/pkg/admin/static/admin.css index ae77064..ce06a92 100644 --- a/pkg/admin/static/admin.css +++ b/pkg/admin/static/admin.css @@ -1 +1 @@ -/*! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-inter),sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*{scrollbar-color:auto;scrollbar-width:auto}:root{--radius:0.5rem;--accent:240 5% 96%;--accent-foreground:240 6% 10%;--background:0 0% 100%;--modal-background:0,0%,100%,0.5;--border:240 6% 90%;--card:0 0% 100%;--card-foreground:240 10% 4%;--destructive:0 72% 51%;--destructive-foreground:0 0% 98%;--elevated:240 5% 96%;--foreground:240 6% 10%;--input:240 6% 90%;--modal-background:0,0%,0%,0.8;--muted:240 5% 96%;--muted-foreground:240 4% 46%;--muted-icon:240 5% 84%;--popover:0 0% 100%;--popover-foreground:240 10% 4%;--primary:263.4 70% 50%;--primary-foreground:0 0% 98%;--reviso:258.3 90% 66%;--reviso-highlight:255,92%,76%,0.25;--ring:255.1 92% 76%;--secondary:0 0% 98%;--secondary-foreground:240 6% 10%;--font-serif:"Spectral",sans-serif;--font-sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-family:var(--font-sans);font-size:.875rem}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.\!container{width:100%!important;margin-right:auto!important;margin-left:auto!important;padding-right:2rem!important;padding-left:2rem!important}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:2rem;padding-left:2rem}@media (min-width:1400px){.\!container{max-width:1400px!important}.container{max-width:1400px}}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-4{margin-top:1rem;margin-bottom:1rem}.-mb-1{margin-bottom:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[calc\(2\.25rem-2px\)\]{height:calc(2.25rem - 2px)}.h-full{height:100%}.h-px{height:1px}.max-h-\[700px\]{max-height:700px}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-10{width:2.5rem}.w-2{width:.5rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-44{width:11rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-\[8rem\]{min-width:8rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-bounce{animation:bounce 1s infinite}@keyframes slideInY{0%{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}.animate-slideInY{animation:slideInY .1s ease-out forwards}@keyframes slideOutY{0%{transform:translateY(0);opacity:1}to{transform:translateY(500%);opacity:0}}.animate-slideOutY{animation:slideOutY .05s ease-in forwards}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.content-center{align-content:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-wrap{text-wrap:wrap}.rounded{border-radius:.25rem}.rounded-\[26px\]{border-radius:26px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-none{border-style:none}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.border-border{border-color:hsl(var(--border))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity))}.border-input{border-color:hsl(var(--input))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.border-reviso{border-color:hsl(var(--reviso))}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.bg-accent{background-color:hsl(var(--accent))}.bg-background{background-color:hsl(var(--background))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-elevated{background-color:hsl(var(--elevated))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-highlight{background-color:hsl(var(--highlight))}.bg-muted{background-color:hsl(var(--muted))}.bg-popover{background-color:hsl(var(--popover))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-reviso{background-color:hsl(var(--reviso))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-transparent{background-color:initial}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity))}.fill-current{fill:currentColor}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-1{padding-left:.25rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-8{padding-left:2rem}.pr-2{padding-right:.5rem}.pr-8{padding-right:2rem}.pt-0{padding-top:0}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-widest{letter-spacing:.1em}.text-background{color:hsl(var(--background))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-foreground{color:hsl(var(--foreground))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity))}.text-highlight-foreground{color:hsl(var(--highlight-foreground))}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-100{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-reviso{color:hsl(var(--reviso))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline-offset-4{text-underline-offset:4px}.opacity-25{opacity:.25}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_2px_4px_-2px_hsla\(220\2c 43\%\2c 11\%\2c 0\.1\)\2c 0_4px_6px_-1px_hsla\(0\2c 0\%\2c 0\%\2c 0\.1\)\]{--tw-shadow:0 2px 4px -2px #1018281a,0 4px 6px -1px #0000001a;--tw-shadow-colored:0 2px 4px -2px var(--tw-shadow-color),0 4px 6px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid #0000;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-offset-background{--tw-ring-offset-color:hsl(var(--background))}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0) scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1)) rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0) scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1)) rotate(var(--tw-exit-rotate,0))}}.duration-300{animation-duration:.3s}.ease-in-out{animation-timing-function:cubic-bezier(.4,0,.2,1)}.\[i\:i\+1\]{i:i+1}rogue-editor{font-family:var(--font-serif);white-space:pre-wrap;word-wrap:break-word}rogue-editor h1{font-size:2.125rem;line-height:3rem;margin:1rem 0}rogue-editor h1,rogue-editor h2{font-family:var(--font-serif);font-weight:700}rogue-editor h2{font-size:1.875rem;line-height:2.625rem;margin:1rem 0 .5rem}rogue-editor h3{font-family:var(--font-serif);font-weight:700;font-size:1.5rem;line-height:2.375rem;margin:1rem 0 .5rem}rogue-editor a{color:#0ea5e9}rogue-editor p{font-size:1.125rem;line-height:1.875rem}rogue-editor h1:empty,rogue-editor h2:empty,rogue-editor h3:empty,rogue-editor h4:empty,rogue-editor h5:empty,rogue-editor h6:empty,rogue-editor p:empty{min-height:1.5em;display:block;width:100%}rogue-editor strong{font-weight:700}rogue-highlight-block{opacity:.5}rogue-caret,rogue-highlight-block{position:absolute;pointer-events:none}rogue-editor ol,rogue-editor ul{font-size:1.125rem;line-height:1.875rem;margin:0 0 1rem;padding-left:0}rogue-editor ol+rogue-editor ul{margin-top:-1rem}rogue-editor ol>li,rogue-editor ul>li{list-style-type:none}rogue-editor ul>li:before{content:"\2022"}rogue-editor ul[data-checked=false],rogue-editor ul[data-checked=true]{pointer-events:none}rogue-editor ul[data-checked=false]>li *,rogue-editor ul[data-checked=true]>li *{pointer-events:all}rogue-editor ul[data-checked=false]>li:before,rogue-editor ul[data-checked=true]>li:before{cursor:pointer;pointer-events:all}rogue-editor ul[data-checked]{margin:0}rogue-editor ul[data-checked] li{list-style-type:none;position:relative}rogue-editor ul[data-checked] li:before{content:"☑️";padding-right:.5em}rogue-editor ul[data-checked=true] li:before{content:"✅"}rogue-editor ul[data-checked=false] li:before{content:"☑️"}rogue-editor li:before{display:inline-block;white-space:nowrap;width:1.2em}rogue-editor li:not(.ql-direction-rtl):before{margin-left:-1.5em;margin-right:.3em;text-align:right}rogue-editor li.ql-direction-rtl:before{margin-left:.3em;margin-right:-1.5em}rogue-editor ol li:not(.ql-direction-rtl),rogue-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}rogue-editor ol li.ql-direction-rtl,rogue-editor ul li.ql-direction-rtl{padding-right:1.5em}rogue-editor ol li{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;counter-increment:list-0}rogue-editor ol li:before{content:counter(list-0,decimal) ". "}rogue-editor ol li.ql-indent-1{counter-increment:list-1;counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}rogue-editor ol li.ql-indent-1:before{content:counter(list-1,lower-alpha) ". "}rogue-editor ol li.ql-indent-2{counter-increment:list-2;counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}rogue-editor ol li.ql-indent-2:before{content:counter(list-2,lower-roman) ". "}rogue-editor ol li.ql-indent-3{counter-increment:list-3;counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}rogue-editor ol li.ql-indent-3:before{content:counter(list-3,decimal) ". "}.first\:mt-auto:first-child{margin-top:auto}.last\:mb-0:last-child{margin-bottom:0}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:hsl(var(--accent))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=open\]\:animate-in[data-state=open]{animation-name:enter;animation-duration:.15s;--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation-name:exit;animation-duration:.15s;--tw-exit-opacity:initial;--tw-exit-scale:initial;--tw-exit-rotate:initial;--tw-exit-translate-x:initial;--tw-exit-translate-y:initial}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:-0.5rem}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:0.5rem}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:-0.5rem}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:0.5rem}.hover\:h-\[calc\(100\%-2px\)\]:hover{height:calc(100% - 2px)}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.hover\:animate-ping:hover{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}.hover\:border-transparent:hover{border-color:#0000}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-background\/90:hover{background-color:hsl(var(--background)/.9)}.hover\:bg-blue-500:hover{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive)/.9)}.hover\:bg-elevated:hover{background-color:hsl(var(--elevated))}.hover\:bg-elevated\/90:hover{background-color:hsl(var(--elevated)/.9)}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.hover\:bg-green-500:hover{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity))}.hover\:bg-highlight\/90:hover{background-color:hsl(var(--highlight)/.9)}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity))}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary)/.8)}.hover\:bg-zinc-900\/90:hover{background-color:#18181be6}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.focus\:bg-accent:focus{background-color:hsl(var(--accent))}.focus\:text-accent-foreground:focus{color:hsl(var(--accent-foreground))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity))}.focus-visible\:outline-none:focus-visible{outline:2px solid #0000;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-gray-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity))}.dark\:border-slate-600:is(.dark *){--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity))}.dark\:border-slate-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity))}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-slate-200:is(.dark *){--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.rtl\:text-right:where([dir=rtl],[dir=rtl] *){text-align:right}@media print{.print\:hidden{display:none}} \ No newline at end of file +/*! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-inter),sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*{scrollbar-color:auto;scrollbar-width:auto}:root{--radius:0.5rem;--accent:240 5% 96%;--accent-foreground:240 6% 10%;--background:0 0% 100%;--modal-background:0,0%,100%,0.5;--border:240 6% 90%;--card:0 0% 100%;--card-foreground:240 10% 4%;--destructive:0 72% 51%;--destructive-foreground:0 0% 98%;--elevated:240 5% 96%;--foreground:240 6% 10%;--input:240 6% 90%;--modal-background:0,0%,0%,0.8;--muted:240 5% 96%;--muted-foreground:240 4% 46%;--muted-icon:240 5% 84%;--popover:0 0% 100%;--popover-foreground:240 10% 4%;--primary:263.4 70% 50%;--primary-foreground:0 0% 98%;--reviso:258.3 90% 66%;--reviso-highlight:255,92%,76%,0.25;--ring:255.1 92% 76%;--secondary:0 0% 98%;--secondary-foreground:240 6% 10%;--font-serif:"Spectral",sans-serif;--font-sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-family:var(--font-sans);font-size:.875rem}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.\!container{width:100%!important;margin-right:auto!important;margin-left:auto!important;padding-right:2rem!important;padding-left:2rem!important}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:2rem;padding-left:2rem}@media (min-width:1400px){.\!container{max-width:1400px!important}.container{max-width:1400px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.right-4{right:1rem}.top-20{top:5rem}.top-4{top:1rem}.z-0{z-index:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-4{margin-top:1rem;margin-bottom:1rem}.-mb-1{margin-bottom:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[calc\(2\.25rem-2px\)\]{height:calc(2.25rem - 2px)}.h-full{height:100%}.h-px{height:1px}.max-h-\[700px\]{max-height:700px}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-10{width:2.5rem}.w-12{width:3rem}.w-2{width:.5rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-44{width:11rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-full{width:100%}.min-w-\[8rem\]{min-width:8rem}.min-w-full{min-width:100%}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-bounce{animation:bounce 1s infinite}@keyframes slideInY{0%{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}.animate-slideInY{animation:slideInY .1s ease-out forwards}@keyframes slideOutY{0%{transform:translateY(0);opacity:1}to{transform:translateY(500%);opacity:0}}.animate-slideOutY{animation:slideOutY .05s ease-in forwards}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.content-center{align-content:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.-space-x-px>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(-1px*var(--tw-space-x-reverse));margin-left:calc(-1px*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-wrap{text-wrap:wrap}.rounded{border-radius:.25rem}.rounded-\[26px\]{border-radius:26px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-l-md{border-top-left-radius:calc(var(--radius) - 2px);border-bottom-left-radius:calc(var(--radius) - 2px)}.rounded-r-md{border-top-right-radius:calc(var(--radius) - 2px);border-bottom-right-radius:calc(var(--radius) - 2px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.border-border{border-color:hsl(var(--border))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity))}.border-input{border-color:hsl(var(--input))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.border-reviso{border-color:hsl(var(--reviso))}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.border-transparent{border-color:#0000}.bg-accent{background-color:hsl(var(--accent))}.bg-background{background-color:hsl(var(--background))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-elevated{background-color:hsl(var(--elevated))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-highlight{background-color:hsl(var(--highlight))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-muted{background-color:hsl(var(--muted))}.bg-popover{background-color:hsl(var(--popover))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.bg-reviso{background-color:hsl(var(--reviso))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-transparent{background-color:initial}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity))}.bg-opacity-50{--tw-bg-opacity:0.5}.fill-current{fill:currentColor}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-12{padding-left:3rem;padding-right:3rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-1{padding-left:.25rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-8{padding-left:2rem}.pr-2{padding-right:.5rem}.pr-8{padding-right:2rem}.pt-0{padding-top:0}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-4{line-height:1rem}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-background{color:hsl(var(--background))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-foreground{color:hsl(var(--foreground))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity))}.text-highlight-foreground{color:hsl(var(--highlight-foreground))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-100{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity))}.text-reviso{color:hsl(var(--reviso))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline-offset-4{text-underline-offset:4px}.opacity-25{opacity:.25}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_2px_4px_-2px_hsla\(220\2c 43\%\2c 11\%\2c 0\.1\)\2c 0_4px_6px_-1px_hsla\(0\2c 0\%\2c 0\%\2c 0\.1\)\]{--tw-shadow:0 2px 4px -2px #1018281a,0 4px 6px -1px #0000001a;--tw-shadow-colored:0 2px 4px -2px var(--tw-shadow-color),0 4px 6px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid #0000;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-offset-background{--tw-ring-offset-color:hsl(var(--background))}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0) scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1)) rotate(var(--tw-enter-rotate,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0) scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1)) rotate(var(--tw-exit-rotate,0))}}.duration-300{animation-duration:.3s}.ease-in-out{animation-timing-function:cubic-bezier(.4,0,.2,1)}.\[i\:i\+1\]{i:i+1}rogue-editor{font-family:var(--font-serif);white-space:pre-wrap;word-wrap:break-word}rogue-editor h1{font-size:2.125rem;line-height:3rem;margin:1rem 0}rogue-editor h1,rogue-editor h2{font-family:var(--font-serif);font-weight:700}rogue-editor h2{font-size:1.875rem;line-height:2.625rem;margin:1rem 0 .5rem}rogue-editor h3{font-family:var(--font-serif);font-weight:700;font-size:1.5rem;line-height:2.375rem;margin:1rem 0 .5rem}rogue-editor a{color:#0ea5e9}rogue-editor p{font-size:1.125rem;line-height:1.875rem}rogue-editor h1:empty,rogue-editor h2:empty,rogue-editor h3:empty,rogue-editor h4:empty,rogue-editor h5:empty,rogue-editor h6:empty,rogue-editor p:empty{min-height:1.5em;display:block;width:100%}rogue-editor strong{font-weight:700}rogue-highlight-block{opacity:.5}rogue-caret,rogue-highlight-block{position:absolute;pointer-events:none}rogue-editor ol,rogue-editor ul{font-size:1.125rem;line-height:1.875rem;margin:0 0 1rem;padding-left:0}rogue-editor ol+rogue-editor ul{margin-top:-1rem}rogue-editor ol>li,rogue-editor ul>li{list-style-type:none}rogue-editor ul>li:before{content:"\2022"}rogue-editor ul[data-checked=false],rogue-editor ul[data-checked=true]{pointer-events:none}rogue-editor ul[data-checked=false]>li *,rogue-editor ul[data-checked=true]>li *{pointer-events:all}rogue-editor ul[data-checked=false]>li:before,rogue-editor ul[data-checked=true]>li:before{cursor:pointer;pointer-events:all}rogue-editor ul[data-checked]{margin:0}rogue-editor ul[data-checked] li{list-style-type:none;position:relative}rogue-editor ul[data-checked] li:before{content:"☑️";padding-right:.5em}rogue-editor ul[data-checked=true] li:before{content:"✅"}rogue-editor ul[data-checked=false] li:before{content:"☑️"}rogue-editor li:before{display:inline-block;white-space:nowrap;width:1.2em}rogue-editor li:not(.ql-direction-rtl):before{margin-left:-1.5em;margin-right:.3em;text-align:right}rogue-editor li.ql-direction-rtl:before{margin-left:.3em;margin-right:-1.5em}rogue-editor ol li:not(.ql-direction-rtl),rogue-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}rogue-editor ol li.ql-direction-rtl,rogue-editor ul li.ql-direction-rtl{padding-right:1.5em}rogue-editor ol li{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;counter-increment:list-0}rogue-editor ol li:before{content:counter(list-0,decimal) ". "}rogue-editor ol li.ql-indent-1{counter-increment:list-1;counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}rogue-editor ol li.ql-indent-1:before{content:counter(list-1,lower-alpha) ". "}rogue-editor ol li.ql-indent-2{counter-increment:list-2;counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}rogue-editor ol li.ql-indent-2:before{content:counter(list-2,lower-roman) ". "}rogue-editor ol li.ql-indent-3{counter-increment:list-3;counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}rogue-editor ol li.ql-indent-3:before{content:counter(list-3,decimal) ". "}.first\:mt-auto:first-child{margin-top:auto}.last\:mb-0:last-child{margin-bottom:0}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:hsl(var(--accent))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=open\]\:animate-in[data-state=open]{animation-name:enter;animation-duration:.15s;--tw-enter-opacity:initial;--tw-enter-scale:initial;--tw-enter-rotate:initial;--tw-enter-translate-x:initial;--tw-enter-translate-y:initial}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation-name:exit;animation-duration:.15s;--tw-exit-opacity:initial;--tw-exit-scale:initial;--tw-exit-rotate:initial;--tw-exit-translate-x:initial;--tw-exit-translate-y:initial}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:-0.5rem}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:0.5rem}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:-0.5rem}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:0.5rem}.hover\:h-\[calc\(100\%-2px\)\]:hover{height:calc(100% - 2px)}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.hover\:animate-ping:hover{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}.hover\:border-transparent:hover{border-color:#0000}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-background\/90:hover{background-color:hsl(var(--background)/.9)}.hover\:bg-blue-500:hover{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive)/.9)}.hover\:bg-elevated:hover{background-color:hsl(var(--elevated))}.hover\:bg-elevated\/90:hover{background-color:hsl(var(--elevated)/.9)}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.hover\:bg-green-500:hover{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity))}.hover\:bg-highlight\/90:hover{background-color:hsl(var(--highlight)/.9)}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity))}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity))}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary)/.8)}.hover\:bg-zinc-900\/90:hover{background-color:#18181be6}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.focus\:border-indigo-500:focus{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity))}.focus\:bg-accent:focus{background-color:hsl(var(--accent))}.focus\:text-accent-foreground:focus{color:hsl(var(--accent-foreground))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity))}.focus\:ring-gray-300:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity))}.focus\:ring-red-300:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(252 165 165/var(--tw-ring-opacity))}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:outline-none:focus-visible{outline:2px solid #0000;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-gray-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity))}.dark\:border-slate-600:is(.dark *){--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity))}.dark\:border-slate-700:is(.dark *){--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity))}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-slate-200:is(.dark *){--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity))}.dark\:text-white:is(.dark *){--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}@media (min-width:640px){.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:flex-1{flex:1 1 0%}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:p-6{padding:1.5rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}.rtl\:text-right:where([dir=rtl],[dir=rtl] *){text-align:right}@media print{.print\:hidden{display:none}} \ No newline at end of file diff --git a/pkg/admin/templates/admin.templ b/pkg/admin/templates/admin.templ index e0e8d73..8b2deb2 100644 --- a/pkg/admin/templates/admin.templ +++ b/pkg/admin/templates/admin.templ @@ -16,6 +16,7 @@ type AdminDocument struct { templ adminNav(current string) { ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -370,12 +401,12 @@ func AdminLayout(page string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var32 := templ.GetChildren(ctx) - if templ_7745c5c3_Var32 == nil { - templ_7745c5c3_Var32 = templ.NopComponent + templ_7745c5c3_Var35 := templ.GetChildren(ctx) + if templ_7745c5c3_Var35 == nil { + templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var33 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var36 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -395,7 +426,7 @@ func AdminLayout(page string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templ_7745c5c3_Var32.Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = templ_7745c5c3_Var35.Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -405,7 +436,7 @@ func AdminLayout(page string) templ.Component { } return templ_7745c5c3_Err }) - templ_7745c5c3_Err = BaseAdmin(page).Render(templ.WithChildren(ctx, templ_7745c5c3_Var33), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BaseAdmin(page).Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -426,12 +457,12 @@ func Admin(docs []AdminDocument) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var34 := templ.GetChildren(ctx) - if templ_7745c5c3_Var34 == nil { - templ_7745c5c3_Var34 = templ.NopComponent + templ_7745c5c3_Var37 := templ.GetChildren(ctx) + if templ_7745c5c3_Var37 == nil { + templ_7745c5c3_Var37 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var35 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var38 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -459,7 +490,7 @@ func Admin(docs []AdminDocument) templ.Component { } return templ_7745c5c3_Err }) - templ_7745c5c3_Err = AdminLayout("docs").Render(templ.WithChildren(ctx, templ_7745c5c3_Var35), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = AdminLayout("docs").Render(templ.WithChildren(ctx, templ_7745c5c3_Var38), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -480,9 +511,9 @@ func document(doc AdminDocument) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var36 := templ.GetChildren(ctx) - if templ_7745c5c3_Var36 == nil { - templ_7745c5c3_Var36 = templ.NopComponent + templ_7745c5c3_Var39 := templ.GetChildren(ctx) + if templ_7745c5c3_Var39 == nil { + templ_7745c5c3_Var39 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" + +
+
+
+ + +
+
+ +
+ if searchEmail != "" { + + } +
+
+ + +
+
+
+

+ Users ({ fmt.Sprintf("%d", pagination.TotalItems) } total) +

+
+ +
+ + + + + + + + + + + for _, user := range users { + @userRow(user) + } + +
+ User + + Details + + Dates + + Actions +
+
+
+
+ + + if pagination.TotalPages > 1 { + @paginationControls(pagination, searchEmail) + } + + + + + + +
+ + + } +} + +templ userRow(user AdminUser) { + + +
+
+
+ { user.DisplayName } +
+
+ { user.Email } +
+
+ ID: { user.ID[:8] }... +
+
+
+ + +
+
Provider: { user.Provider }
+ if user.StripeCustomerID != "" { +
Stripe: { user.StripeCustomerID[:18] }...
+ } +
+ if user.Admin { + + Admin + + } + if user.Educator { + + Educator + + } +
+
+ + +
Created: { user.CreatedAt.Format("2006-01-02 15:04") }
+
Updated: { user.UpdatedAt.Format("2006-01-02 15:04") }
+ + + + + +} + +templ paginationControls(pagination PaginationInfo, searchEmail string) { +
+
+ if pagination.HasPrevious { + + Previous + + } + if pagination.HasNext { + + Next + + } +
+ +
+} + +templ DeleteUserSuccess(userID string, result *users.UserDeletionResult) { +
+
+ + + +
+

User deleted successfully!

+

+ Removed { fmt.Sprintf("%d", result.DeletedPostgresqlRecords) } database records, + { fmt.Sprintf("%d", result.DeletedDynamoDBRecords) } DynamoDB records, + and { fmt.Sprintf("%d", result.DeletedS3Objects) } S3 objects. +

+
+
+
+} + +templ DeleteUserError(userID string, errorMessage string) { +
+
+ + + +
+

Failed to delete user

+

Error: { errorMessage }

+
+
+
+} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} \ No newline at end of file diff --git a/pkg/admin/templates/users_templ.go b/pkg/admin/templates/users_templ.go new file mode 100644 index 0000000..b736d2c --- /dev/null +++ b/pkg/admin/templates/users_templ.go @@ -0,0 +1,622 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.771 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/fivetentaylor/pointy/pkg/service/users" + "time" +) + +type AdminUser struct { + ID string + Name string + Email string + DisplayName string + Provider string + Admin bool + Educator bool + StripeCustomerID string + CreatedAt time.Time + UpdatedAt time.Time +} + +type PaginationInfo struct { + CurrentPage int + TotalPages int + TotalItems int + ItemsPerPage int + HasPrevious bool + HasNext bool + PreviousPage int + NextPage int +} + +func Users(users []AdminUser, pagination PaginationInfo, searchEmail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if searchEmail != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Users (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", pagination.TotalItems)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 76, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" total)

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, user := range users { + templ_7745c5c3_Err = userRow(user).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
UserDetailsDatesActions
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if pagination.TotalPages > 1 { + templ_7745c5c3_Err = paginationControls(pagination, searchEmail).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Delete User

Are you sure you want to delete this user? This will permanently remove all their data including documents, comments, and files. This action cannot be undone.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = AdminLayout("users").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func userRow(user AdminUser) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 250, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 253, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
ID: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(user.ID[:8]) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 256, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("...
Provider: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(user.Provider) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 263, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if user.StripeCustomerID != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Stripe: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(user.StripeCustomerID[:18]) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 265, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("...
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if user.Admin { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Admin ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if user.Educator { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Educator") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Created: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(user.CreatedAt.Format("2006-01-02 15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 282, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Updated: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(user.UpdatedAt.Format("2006-01-02 15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 283, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func paginationControls(pagination PaginationInfo, searchEmail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if pagination.HasPrevious { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Previous ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if pagination.HasNext { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Next") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Showing ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", (pagination.CurrentPage-1)*pagination.ItemsPerPage+1)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 321, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" to ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", minInt(pagination.CurrentPage*pagination.ItemsPerPage, pagination.TotalItems))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 324, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" of ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", pagination.TotalItems)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 327, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" results

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func DeleteUserSuccess(userID string, result *users.UserDeletionResult) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

User deleted successfully!

Removed ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", result.DeletedPostgresqlRecords)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 386, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" database records, ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", result.DeletedDynamoDBRecords)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 387, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" DynamoDB records, and ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", result.DeletedS3Objects)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 388, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" S3 objects.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func DeleteUserError(userID string, errorMessage string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Failed to delete user

Error: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin/templates/users.templ`, Line: 403, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +var _ = templruntime.GeneratedTemplate diff --git a/pkg/admin/users.go b/pkg/admin/users.go new file mode 100644 index 0000000..d8bd85b --- /dev/null +++ b/pkg/admin/users.go @@ -0,0 +1,147 @@ +package admin + +import ( + "net/http" + "strconv" + + "github.com/charmbracelet/log" + "github.com/go-chi/chi/v5" + + "github.com/fivetentaylor/pointy/pkg/admin/templates" + "github.com/fivetentaylor/pointy/pkg/env" + "github.com/fivetentaylor/pointy/pkg/models" + "github.com/fivetentaylor/pointy/pkg/query" + "github.com/fivetentaylor/pointy/pkg/service/users" + dynamodb_storage "github.com/fivetentaylor/pointy/pkg/storage/dynamo" + "github.com/fivetentaylor/pointy/pkg/storage/s3" +) + +const usersPerPage = 20 + +func GetUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Parse query parameters + pageStr := r.URL.Query().Get("page") + searchEmail := r.URL.Query().Get("search") + + page := 1 + if pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + offset := (page - 1) * usersPerPage + + q := query.Use(env.RawDB(ctx)) + + // Build query for count + var totalUsers int64 + var err error + if searchEmail != "" { + totalUsers, err = q.User.Where(q.User.Email.Like("%" + searchEmail + "%")).Count() + } else { + totalUsers, err = q.User.Count() + } + if err != nil { + log.Errorf("Failed to count users: %v", err) + http.Error(w, "Failed to count users", http.StatusInternalServerError) + return + } + + // Get users for current page + var usersList []*models.User + if searchEmail != "" { + usersList, err = q.User.Where(q.User.Email.Like("%" + searchEmail + "%")). + Order(q.User.CreatedAt.Desc()). + Offset(offset). + Limit(usersPerPage). + Find() + } else { + usersList, err = q.User.Order(q.User.CreatedAt.Desc()). + Offset(offset). + Limit(usersPerPage). + Find() + } + if err != nil { + log.Errorf("Failed to get users: %v", err) + http.Error(w, "Failed to get users", http.StatusInternalServerError) + return + } + + // Convert to template format + adminUsers := make([]templates.AdminUser, len(usersList)) + for i, user := range usersList { + adminUsers[i] = templates.AdminUser{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + DisplayName: user.DisplayName, + Provider: user.Provider, + Admin: user.Admin, + Educator: user.Educator, + StripeCustomerID: user.StripeCustomerID, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + } + + totalPages := int((totalUsers + int64(usersPerPage) - 1) / int64(usersPerPage)) + + paginationInfo := templates.PaginationInfo{ + CurrentPage: page, + TotalPages: totalPages, + TotalItems: int(totalUsers), + ItemsPerPage: usersPerPage, + HasPrevious: page > 1, + HasNext: page < totalPages, + PreviousPage: page - 1, + NextPage: page + 1, + } + + templates.Users(adminUsers, paginationInfo, searchEmail).Render(ctx, w) +} + +func DeleteUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userID := chi.URLParam(r, "userID") + + if userID == "" { + http.Error(w, "User ID is required", http.StatusBadRequest) + return + } + + // Create deletion service + dynamoDB, err := dynamodb_storage.NewDB() + if err != nil { + log.Errorf("Failed to create DynamoDB client: %v", err) + http.Error(w, "Failed to initialize deletion service", http.StatusInternalServerError) + return + } + + s3Client, err := s3.NewS3() + if err != nil { + log.Errorf("Failed to create S3 client: %v", err) + http.Error(w, "Failed to initialize deletion service", http.StatusInternalServerError) + return + } + + deletionService := users.NewUserDeletionService(env.RawDB(ctx), dynamoDB, s3Client) + + // Perform user deletion + result, err := deletionService.DeleteUser(ctx, userID) + if err != nil { + log.Errorf("User deletion failed for %s: %v", userID, err) + w.Header().Set("HX-Trigger", "user-delete-error") + templates.DeleteUserError(userID, err.Error()).Render(ctx, w) + return + } + + log.Infof("Successfully deleted user %s: PG=%d, Dynamo=%d, S3=%d", + userID, result.DeletedPostgresqlRecords, result.DeletedDynamoDBRecords, result.DeletedS3Objects) + + // Return success response for HTMX + w.Header().Set("HX-Trigger", "user-deleted") + templates.DeleteUserSuccess(userID, result).Render(ctx, w) +} \ No newline at end of file diff --git a/pkg/service/users/delete.go b/pkg/service/users/delete.go new file mode 100644 index 0000000..aac84b8 --- /dev/null +++ b/pkg/service/users/delete.go @@ -0,0 +1,435 @@ +package users + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/charmbracelet/log" + "github.com/fivetentaylor/pointy/pkg/constants" + "github.com/fivetentaylor/pointy/pkg/query" + dynamodb_storage "github.com/fivetentaylor/pointy/pkg/storage/dynamo" + "github.com/fivetentaylor/pointy/pkg/storage/s3" + "gorm.io/gorm" +) + +type UserDeletionService struct { + DB *gorm.DB + DynamoDB *dynamodb_storage.DB + S3 *s3.S3 +} + +type UserDeletionResult struct { + UserID string + DeletedPostgresqlRecords int + DeletedDynamoDBRecords int + DeletedS3Objects int + Errors []string + CompletedAt time.Time +} + +func NewUserDeletionService(db *gorm.DB, dynamodb *dynamodb_storage.DB, s3Client *s3.S3) *UserDeletionService { + return &UserDeletionService{ + DB: db, + DynamoDB: dynamodb, + S3: s3Client, + } +} + +// DeleteUser performs a comprehensive deletion of all user data across PostgreSQL, DynamoDB, and S3 +func (uds *UserDeletionService) DeleteUser(ctx context.Context, userID string) (*UserDeletionResult, error) { + log.Infof("Starting comprehensive user deletion for userID: %s", userID) + + result := &UserDeletionResult{ + UserID: userID, + Errors: []string{}, + } + + // Step 1: Delete PostgreSQL records (in proper order due to foreign key constraints) + pgCount, err := uds.deletePostgreSQLData(ctx, userID) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("PostgreSQL deletion error: %v", err)) + log.Errorf("PostgreSQL deletion failed: %v", err) + } else { + result.DeletedPostgresqlRecords = pgCount + log.Infof("Deleted %d PostgreSQL records for user %s", pgCount, userID) + } + + // Step 2: Delete DynamoDB records + dynamoCount, err := uds.deleteDynamoDBData(ctx, userID) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("DynamoDB deletion error: %v", err)) + log.Errorf("DynamoDB deletion failed: %v", err) + } else { + result.DeletedDynamoDBRecords = dynamoCount + log.Infof("Deleted %d DynamoDB records for user %s", dynamoCount, userID) + } + + // Step 3: Delete S3 objects + s3Count, err := uds.deleteS3Data(ctx, userID) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("S3 deletion error: %v", err)) + log.Errorf("S3 deletion failed: %v", err) + } else { + result.DeletedS3Objects = s3Count + log.Infof("Deleted %d S3 objects for user %s", s3Count, userID) + } + + result.CompletedAt = time.Now() + + if len(result.Errors) > 0 { + log.Warnf("User deletion completed with %d errors for user %s", len(result.Errors), userID) + return result, fmt.Errorf("user deletion completed with errors: %v", result.Errors) + } + + log.Infof("Successfully completed user deletion for userID: %s", userID) + return result, nil +} + +// deletePostgreSQLData removes all user data from PostgreSQL tables +func (uds *UserDeletionService) deletePostgreSQLData(ctx context.Context, userID string) (int, error) { + q := query.Use(uds.DB) + totalDeleted := 0 + + // Delete in order to handle foreign key constraints + tables := []struct { + name string + delete func() (int64, error) + }{ + // Delete dependent records first + {"comments", func() (int64, error) { + result, err := q.Comment.Unscoped().Where(q.Comment.UserID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"document_attachments", func() (int64, error) { + result, err := q.DocumentAttachment.Where(q.DocumentAttachment.UserID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"document_versions (created_by)", func() (int64, error) { + result, err := q.DocumentVersion.Where(q.DocumentVersion.CreatedBy.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"document_versions (updated_by)", func() (int64, error) { + result, err := q.DocumentVersion.Where(q.DocumentVersion.UpdatedBy.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"document_access", func() (int64, error) { + result, err := q.DocumentAccess.Where(q.DocumentAccess.UserID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"shared_document_links", func() (int64, error) { + result, err := q.SharedDocumentLink.Where(q.SharedDocumentLink.InviterID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"author_ids", func() (int64, error) { + result, err := q.AuthorID.Where(q.AuthorID.UserID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"one_time_access_tokens", func() (int64, error) { + result, err := q.OneTimeAccessToken.Where(q.OneTimeAccessToken.UserID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"payment_history", func() (int64, error) { + result, err := q.PaymentHistory.Where(q.PaymentHistory.UserID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + {"user_subscriptions", func() (int64, error) { + result, err := q.UserSubscription.Where(q.UserSubscription.UserID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + // Finally delete the main user record + {"users", func() (int64, error) { + result, err := q.User.Where(q.User.ID.Eq(userID)).Delete() + return result.RowsAffected, err + }}, + } + + for _, table := range tables { + deleted, err := table.delete() + if err != nil { + return totalDeleted, fmt.Errorf("failed to delete from %s: %w", table.name, err) + } + totalDeleted += int(deleted) + if deleted > 0 { + log.Debugf("Deleted %d records from %s for user %s", deleted, table.name, userID) + } + } + + return totalDeleted, nil +} + +// deleteDynamoDBData removes all user data from DynamoDB +func (uds *UserDeletionService) deleteDynamoDBData(ctx context.Context, userID string) (int, error) { + totalDeleted := 0 + + // Delete messages where user is the author + msgCount, err := uds.deleteUserMessages(userID) + if err != nil { + return totalDeleted, fmt.Errorf("failed to delete user messages: %w", err) + } + totalDeleted += msgCount + + // Delete timeline events for the user + timelineCount, err := uds.deleteUserTimelineEvents(userID) + if err != nil { + return totalDeleted, fmt.Errorf("failed to delete user timeline events: %w", err) + } + totalDeleted += timelineCount + + // Delete notifications for the user + notifCount, err := uds.deleteUserNotifications(userID) + if err != nil { + return totalDeleted, fmt.Errorf("failed to delete user notifications: %w", err) + } + totalDeleted += notifCount + + // Delete user preferences + prefCount, err := uds.deleteUserPreferences(userID) + if err != nil { + return totalDeleted, fmt.Errorf("failed to delete user preferences: %w", err) + } + totalDeleted += prefCount + + return totalDeleted, nil +} + +// deleteUserMessages removes all messages created by the user +func (uds *UserDeletionService) deleteUserMessages(userID string) (int, error) { + // This is a complex operation as we need to scan for messages by userID + // In a production environment, you might want to add a GSI on userID for efficiency + + // For now, we'll need to scan the table and filter by userID + // This is inefficient but necessary without a GSI on userID + + log.Warnf("Scanning DynamoDB table for user messages - this may be slow without GSI on userID") + + input := &dynamodb.ScanInput{ + TableName: &uds.DynamoDB.TableName, + FilterExpression: aws.String("userID = :userID"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":userID": {S: aws.String(userID)}, + }, + } + + deleted := 0 + err := uds.DynamoDB.Client.ScanPages(input, func(page *dynamodb.ScanOutput, lastPage bool) bool { + for _, item := range page.Items { + // Extract PK and SK for deletion + if pk, exists := item["PK"]; exists && pk.S != nil { + if sk, exists := item["SK"]; exists && sk.S != nil { + deleteInput := &dynamodb.DeleteItemInput{ + TableName: &uds.DynamoDB.TableName, + Key: map[string]*dynamodb.AttributeValue{ + "PK": pk, + "SK": sk, + }, + } + + _, err := uds.DynamoDB.Client.DeleteItem(deleteInput) + if err != nil { + log.Errorf("Failed to delete DynamoDB item %s/%s: %v", *pk.S, *sk.S, err) + return false + } + deleted++ + } + } + } + return true + }) + + return deleted, err +} + +// deleteUserTimelineEvents removes all timeline events for the user +func (uds *UserDeletionService) deleteUserTimelineEvents(userID string) (int, error) { + // Similar to messages, this requires a scan unless there's a GSI on userID + input := &dynamodb.ScanInput{ + TableName: &uds.DynamoDB.TableName, + FilterExpression: aws.String("userID = :userID AND begins_with(SK, :timePrefix)"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":userID": {S: aws.String(userID)}, + ":timePrefix": {S: aws.String("time#")}, + }, + } + + deleted := 0 + err := uds.DynamoDB.Client.ScanPages(input, func(page *dynamodb.ScanOutput, lastPage bool) bool { + for _, item := range page.Items { + if pk, exists := item["PK"]; exists && pk.S != nil { + if sk, exists := item["SK"]; exists && sk.S != nil { + deleteInput := &dynamodb.DeleteItemInput{ + TableName: &uds.DynamoDB.TableName, + Key: map[string]*dynamodb.AttributeValue{ + "PK": pk, + "SK": sk, + }, + } + + _, err := uds.DynamoDB.Client.DeleteItem(deleteInput) + if err != nil { + log.Errorf("Failed to delete timeline event %s/%s: %v", *pk.S, *sk.S, err) + return false + } + deleted++ + } + } + } + return true + }) + + return deleted, err +} + +// deleteUserNotifications removes all notifications for the user +func (uds *UserDeletionService) deleteUserNotifications(userID string) (int, error) { + // Notifications use PK pattern: notif#userID + pk := fmt.Sprintf("notif#%s", userID) + + input := &dynamodb.QueryInput{ + TableName: &uds.DynamoDB.TableName, + KeyConditionExpression: aws.String("PK = :pk"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":pk": {S: aws.String(pk)}, + }, + } + + deleted := 0 + err := uds.DynamoDB.Client.QueryPages(input, func(page *dynamodb.QueryOutput, lastPage bool) bool { + for _, item := range page.Items { + if sk, exists := item["SK"]; exists && sk.S != nil { + deleteInput := &dynamodb.DeleteItemInput{ + TableName: &uds.DynamoDB.TableName, + Key: map[string]*dynamodb.AttributeValue{ + "PK": {S: aws.String(pk)}, + "SK": sk, + }, + } + + _, err := uds.DynamoDB.Client.DeleteItem(deleteInput) + if err != nil { + log.Errorf("Failed to delete notification %s/%s: %v", pk, *sk.S, err) + return false + } + deleted++ + } + } + return true + }) + + return deleted, err +} + +// deleteUserPreferences removes user preferences and document preferences +func (uds *UserDeletionService) deleteUserPreferences(userID string) (int, error) { + deleted := 0 + + // Delete user preferences (PK: userPref#userID) + userPrefPK := fmt.Sprintf("userPref#%s", userID) + deleteInput := &dynamodb.DeleteItemInput{ + TableName: &uds.DynamoDB.TableName, + Key: map[string]*dynamodb.AttributeValue{ + "PK": {S: aws.String(userPrefPK)}, + "SK": {S: aws.String(userID)}, + }, + ReturnValues: aws.String("ALL_OLD"), // This will return the deleted item if it existed + } + + result, err := uds.DynamoDB.Client.DeleteItem(deleteInput) + if err != nil { + log.Errorf("Failed to delete user preferences: %v", err) + } else if result.Attributes != nil { + // Only count as deleted if there were actually attributes returned (item existed) + deleted++ + } + + // Delete document notification preferences (PK: docNotifPref#userID) + docPrefPK := fmt.Sprintf("docNotifPref#%s", userID) + + queryInput := &dynamodb.QueryInput{ + TableName: &uds.DynamoDB.TableName, + KeyConditionExpression: aws.String("PK = :pk"), + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":pk": {S: aws.String(docPrefPK)}, + }, + } + + err = uds.DynamoDB.Client.QueryPages(queryInput, func(page *dynamodb.QueryOutput, lastPage bool) bool { + for _, item := range page.Items { + if sk, exists := item["SK"]; exists && sk.S != nil { + deleteInput := &dynamodb.DeleteItemInput{ + TableName: &uds.DynamoDB.TableName, + Key: map[string]*dynamodb.AttributeValue{ + "PK": {S: aws.String(docPrefPK)}, + "SK": sk, + }, + } + + _, err := uds.DynamoDB.Client.DeleteItem(deleteInput) + if err != nil { + log.Errorf("Failed to delete document preference %s/%s: %v", docPrefPK, *sk.S, err) + return false + } + deleted++ + } + } + return true + }) + + return deleted, err +} + +// deleteS3Data removes all S3 objects associated with the user +func (uds *UserDeletionService) deleteS3Data(ctx context.Context, userID string) (int, error) { + totalDeleted := 0 + + // Delete user avatar + avatarKey := fmt.Sprintf(constants.UserAvatarKeyFormat, userID) + exists, err := uds.S3.Exists(uds.S3.ImagesBucket, avatarKey) + if err != nil { + log.Errorf("Failed to check if avatar exists: %v", err) + } else if exists { + err = uds.S3.DeleteAll(uds.S3.ImagesBucket, avatarKey) + if err != nil { + return totalDeleted, fmt.Errorf("failed to delete user avatar: %w", err) + } + totalDeleted++ + log.Debugf("Deleted user avatar: %s", avatarKey) + } + + // Delete user's document attachments + // We need to find all attachments uploaded by this user from PostgreSQL first + q := query.Use(uds.DB) + attachments, err := q.DocumentAttachment.Where(q.DocumentAttachment.UserID.Eq(userID)).Find() + if err != nil { + return totalDeleted, fmt.Errorf("failed to query user attachments: %w", err) + } + + for _, attachment := range attachments { + // Delete original attachment + originalKey := fmt.Sprintf(constants.DocumentAttachmentOriginalKey, attachment.S3ID) + err = uds.S3.DeleteAll(uds.S3.Bucket, originalKey) + if err != nil { + log.Errorf("Failed to delete attachment original %s: %v", originalKey, err) + } else { + totalDeleted++ + } + + // Delete attachment file + fileKey := fmt.Sprintf(constants.DocumentAttachmentFileKey, attachment.S3ID, attachment.Filename) + err = uds.S3.DeleteAll(uds.S3.Bucket, fileKey) + if err != nil { + log.Errorf("Failed to delete attachment file %s: %v", fileKey, err) + } else { + totalDeleted++ + } + } + + // Note: Document images are tied to documents, not users directly + // They should be cleaned up when documents are deleted + // If you need to delete user-specific document images, you would need + // additional logic to find which images were created by this user + + return totalDeleted, nil +} \ No newline at end of file diff --git a/pkg/service/users/delete_test.go b/pkg/service/users/delete_test.go new file mode 100644 index 0000000..b17af46 --- /dev/null +++ b/pkg/service/users/delete_test.go @@ -0,0 +1,525 @@ +package users + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/fivetentaylor/pointy/pkg/constants" + "github.com/fivetentaylor/pointy/pkg/models" + "github.com/fivetentaylor/pointy/pkg/query" + dynamodb_storage "github.com/fivetentaylor/pointy/pkg/storage/dynamo" + "github.com/fivetentaylor/pointy/pkg/storage/s3" + "github.com/fivetentaylor/pointy/pkg/testutils" +) + +type TestData struct { + User *models.User + Document *models.Document + Comment *models.Comment + DocumentAccess *models.DocumentAccess + Attachment *models.DocumentAttachment + DocumentVersion *models.DocumentVersion + AuthorID *models.AuthorID + AccessToken *models.OneTimeAccessToken + PaymentHistory *models.PaymentHistory + SharedLink *models.SharedDocumentLink + UserSubscription *models.UserSubscription + Message *dynamodb_storage.Message + TimelineEvent *dynamodb_storage.TimelineEvent + Notification *dynamodb_storage.Notification + UserPreference *dynamodb_storage.UserPreference + DocPreference *dynamodb_storage.DocPreference + AvatarKey string + AttachmentKeys []string +} + +func TestDeleteUser_ComprehensiveTest(t *testing.T) { + // Setup test environment + ctx := context.Background() + + // Ensure storage is available + testutils.EnsureStorage() + + // Get test context which includes all dependencies + ctx = testutils.TestContext() + + // Extract dependencies from context + db := testutils.TestGormDb(t) + dynamoDB, err := dynamodb_storage.NewDB() + require.NoError(t, err, "Failed to create DynamoDB client") + + s3Client, err := s3.NewS3() + require.NoError(t, err, "Failed to create S3 client") + + // Create deletion service + deletionService := NewUserDeletionService(db, dynamoDB, s3Client) + + // Create comprehensive test data + testData := createComprehensiveTestData(t, db, dynamoDB, s3Client) + userID := testData.User.ID + + // Verify data exists before deletion + verifyDataExists(t, db, dynamoDB, s3Client, testData) + + // Perform user deletion + result, err := deletionService.DeleteUser(ctx, userID) + + // Assert deletion succeeded + if len(result.Errors) > 0 { + t.Logf("Deletion completed with errors: %v", result.Errors) + } + require.NoError(t, err, "User deletion should succeed") + assert.NotZero(t, result.DeletedPostgresqlRecords, "Should have deleted PostgreSQL records") + assert.NotZero(t, result.DeletedDynamoDBRecords, "Should have deleted DynamoDB records") + // S3 deletion might be 0 if no avatar/attachments were actually created + assert.GreaterOrEqual(t, result.DeletedS3Objects, 0, "S3 deletion count should be non-negative") + + // Verify all data was deleted + verifyDataDeleted(t, db, dynamoDB, s3Client, testData) + + t.Logf("Successfully deleted user %s: PG=%d, Dynamo=%d, S3=%d", + userID, result.DeletedPostgresqlRecords, result.DeletedDynamoDBRecords, result.DeletedS3Objects) +} + +func createComprehensiveTestData(t *testing.T, db *gorm.DB, dynamoDB *dynamodb_storage.DB, s3Client *s3.S3) *TestData { + q := query.Use(db) + userID := uuid.NewString() + docID := uuid.NewString() + + testData := &TestData{} + + // Create main user + testData.User = &models.User{ + ID: userID, + Name: "Test User", + Email: fmt.Sprintf("test-%s@example.com", userID[:8]), + Provider: "test", + DisplayName: "Test User Display", + StripeCustomerID: "cus_test_" + userID[:8], + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err := q.User.Create(testData.User) + require.NoError(t, err, "Failed to create test user") + + // Create document for relationships + testData.Document = &models.Document{ + ID: docID, + Title: "Test Document", + RootParentID: docID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = q.Document.Create(testData.Document) + require.NoError(t, err, "Failed to create test document") + + // Create comment + now := time.Now() + testData.Comment = &models.Comment{ + ID: uuid.NewString(), + DocumentID: docID, + ThreadID: uuid.NewString(), + UserID: userID, + Body: "Test comment", + CreatedAt: now, + UpdatedAt: now, + } + err = q.Comment.Create(testData.Comment) + require.NoError(t, err, "Failed to create test comment") + + // Create document access + testData.DocumentAccess = &models.DocumentAccess{ + DocumentID: docID, + UserID: userID, + AccessLevel: "owner", + } + err = q.DocumentAccess.Create(testData.DocumentAccess) + require.NoError(t, err, "Failed to create document access") + + // Create document attachment + attachmentID := uuid.NewString() + testData.Attachment = &models.DocumentAttachment{ + ID: uuid.NewString(), + UserID: userID, + DocumentID: docID, + S3ID: attachmentID, + Filename: "test-file.txt", + ContentType: "text/plain", + Size: 1024, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = q.DocumentAttachment.Create(testData.Attachment) + require.NoError(t, err, "Failed to create document attachment") + + // Create document version + testData.DocumentVersion = &models.DocumentVersion{ + ID: uuid.NewString(), + DocumentID: docID, + Name: "Test Version", + ContentAddress: "test-address", + CreatedBy: userID, + UpdatedBy: userID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = q.DocumentVersion.Create(testData.DocumentVersion) + require.NoError(t, err, "Failed to create document version") + + // Create author ID + testData.AuthorID = &models.AuthorID{ + AuthorID: 1, + DocumentID: docID, + UserID: userID, + CreatedAt: time.Now(), + } + err = q.AuthorID.Create(testData.AuthorID) + require.NoError(t, err, "Failed to create author ID") + + // Create access token + testData.AccessToken = &models.OneTimeAccessToken{ + UserID: userID, + Token: "test-token-" + userID[:8], + ExpiresAt: time.Now().Add(24 * time.Hour), + IsUsed: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = q.OneTimeAccessToken.Create(testData.AccessToken) + require.NoError(t, err, "Failed to create access token") + + // Create payment history + testData.PaymentHistory = &models.PaymentHistory{ + ID: uuid.NewString(), + UserID: userID, + StripePaymentIntentID: "pi_test_" + userID[:8], + AmountCents: 1000, + Currency: "USD", + Status: "succeeded", + CreatedAt: time.Now(), + } + err = q.PaymentHistory.Create(testData.PaymentHistory) + require.NoError(t, err, "Failed to create payment history") + + // Create shared document link + testData.SharedLink = &models.SharedDocumentLink{ + DocumentID: docID, + InviterID: userID, + InviteeEmail: "invitee@example.com", + InviteLink: "testlink", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + IsActive: true, + } + err = q.SharedDocumentLink.Create(testData.SharedLink) + require.NoError(t, err, "Failed to create shared document link") + + // Create subscription plan first (needed for user subscription) + subscriptionPlan := &models.SubscriptionPlan{ + ID: uuid.NewString(), + Name: "Test Plan", + PriceCents: 999, + Currency: "USD", + Interval: "month", + Status: "active", + StripePriceID: "price_test_123", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = q.SubscriptionPlan.Create(subscriptionPlan) + require.NoError(t, err, "Failed to create subscription plan") + + // Create user subscription + testData.UserSubscription = &models.UserSubscription{ + ID: uuid.NewString(), + UserID: userID, + SubscriptionPlanID: subscriptionPlan.ID, + StripeSubscriptionID: "sub_test_" + userID[:8], + Status: "active", + CurrentPeriodStart: time.Now(), + CurrentPeriodEnd: time.Now().Add(30 * 24 * time.Hour), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = q.UserSubscription.Create(testData.UserSubscription) + require.NoError(t, err, "Failed to create user subscription") + + // Create DynamoDB data + createDynamoDBTestData(t, dynamoDB, userID, docID, testData) + + // Create S3 data + createS3TestData(t, s3Client, userID, attachmentID, testData) + + return testData +} + +func createDynamoDBTestData(t *testing.T, dynamoDB *dynamodb_storage.DB, userID, docID string, testData *TestData) { + // Create message + testData.Message = &dynamodb_storage.Message{ + ContainerID: "chan#" + uuid.NewString(), + MessageID: uuid.NewString(), + Chain: "main", + CreatedAt: time.Now().UnixNano(), + UserID: userID, + AuthorID: "1", + ChannelID: uuid.NewString(), + Content: "Test message", + DocID: docID, + LifecycleStage: dynamodb_storage.MessageLifecycleStageCompleted, + Attachments: &models.AttachmentList{}, + AIContent: &models.AIContent{}, + MessageMetadata: &models.MessageMetadata{}, + } + err := dynamoDB.CreateMessage(testData.Message) + require.NoError(t, err, "Failed to create test message") + + // Create timeline event + testData.TimelineEvent = &dynamodb_storage.TimelineEvent{ + DocID: "doc#" + docID, + UserID: userID, + AuthorID: "1", + Event: &models.TimelineEventPayload{}, + } + err = dynamoDB.CreateTimelineEvent(testData.TimelineEvent) + require.NoError(t, err, "Failed to create timeline event") + + // Create notification + testData.Notification = &dynamodb_storage.Notification{ + UserID: userID, + ID: uuid.NewString(), + DocID: docID, + Read: false, + CreatedAt: time.Now().UnixNano(), + Payload: &models.NotificationPayload{}, + } + err = dynamoDB.UpsertNotification(testData.Notification) + require.NoError(t, err, "Failed to create notification") + + // Create user preference + testData.UserPreference = &dynamodb_storage.UserPreference{ + UserID: userID, + Preference: &models.UserPreference{ + EnableActivityNotifications: true, + UnreadActivityFrequencyMinutes: 5, + }, + } + err = dynamoDB.UpsertUserPreference(testData.UserPreference) + require.NoError(t, err, "Failed to create user preference") + + // Create document preference + testData.DocPreference = &dynamodb_storage.DocPreference{ + UserID: userID, + DocID: docID, + Preference: &models.DocumentPreference{ + EnableFirstOpenNotifications: true, + EnableAllCommentNotifications: false, + EnableMentionNotifications: true, + EnableDmNotifications: true, + }, + } + err = dynamoDB.UpsertDocNotificationPreference(testData.DocPreference) + require.NoError(t, err, "Failed to create document preference") +} + +func createS3TestData(t *testing.T, s3Client *s3.S3, userID, attachmentID string, testData *TestData) { + // Create user avatar + testData.AvatarKey = fmt.Sprintf(constants.UserAvatarKeyFormat, userID) + avatarData := []byte("fake-avatar-image-data") + err := s3Client.PutObject(s3Client.ImagesBucket, testData.AvatarKey, "image/png", avatarData) + if err != nil { + t.Logf("Failed to create avatar (may be expected in test): %v", err) + } + + // Create attachment files + originalKey := fmt.Sprintf(constants.DocumentAttachmentOriginalKey, attachmentID) + fileKey := fmt.Sprintf(constants.DocumentAttachmentFileKey, attachmentID, "test-file.txt") + + testData.AttachmentKeys = []string{originalKey, fileKey} + + attachmentData := []byte("fake-attachment-data") + + err = s3Client.PutObject(s3Client.Bucket, originalKey, "text/plain", attachmentData) + if err != nil { + t.Logf("Failed to create attachment original (may be expected in test): %v", err) + } + + err = s3Client.PutObject(s3Client.Bucket, fileKey, "text/plain", attachmentData) + if err != nil { + t.Logf("Failed to create attachment file (may be expected in test): %v", err) + } +} + +func verifyDataExists(t *testing.T, db *gorm.DB, dynamoDB *dynamodb_storage.DB, s3Client *s3.S3, testData *TestData) { + q := query.Use(db) + userID := testData.User.ID + + // Verify PostgreSQL data exists + count, err := q.User.Where(q.User.ID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(1), count, "User should exist") + + count, err = q.Comment.Where(q.Comment.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(1), count, "Comment should exist") + + count, err = q.DocumentAccess.Where(q.DocumentAccess.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(1), count, "Document access should exist") + + // Verify DynamoDB data exists + notification, err := dynamoDB.GetNotification(userID, testData.Notification.ID) + require.NoError(t, err) + assert.NotNil(t, notification, "Notification should exist") + + userPref, err := dynamoDB.GetUserPreference(userID) + require.NoError(t, err) + assert.NotNil(t, userPref, "User preference should exist") + + // Note: S3 verification is optional since test S3 might not work in all environments +} + +func verifyDataDeleted(t *testing.T, db *gorm.DB, dynamoDB *dynamodb_storage.DB, s3Client *s3.S3, testData *TestData) { + q := query.Use(db) + userID := testData.User.ID + + // Verify PostgreSQL data deleted + count, err := q.User.Where(q.User.ID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "User should be deleted") + + count, err = q.Comment.Where(q.Comment.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "Comments should be deleted") + + count, err = q.DocumentAccess.Where(q.DocumentAccess.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "Document access should be deleted") + + count, err = q.DocumentAttachment.Where(q.DocumentAttachment.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "Document attachments should be deleted") + + count, err = q.AuthorID.Where(q.AuthorID.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "Author IDs should be deleted") + + count, err = q.OneTimeAccessToken.Where(q.OneTimeAccessToken.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "Access tokens should be deleted") + + count, err = q.PaymentHistory.Where(q.PaymentHistory.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "Payment history should be deleted") + + count, err = q.SharedDocumentLink.Where(q.SharedDocumentLink.InviterID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "Shared document links should be deleted") + + count, err = q.UserSubscription.Where(q.UserSubscription.UserID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "User subscriptions should be deleted") + + // Verify DynamoDB data deleted + _, err = dynamoDB.GetNotification(userID, testData.Notification.ID) + assert.Error(t, err, "Notification should not exist") + assert.True(t, strings.Contains(err.Error(), "notification not found"), "Should get not found error") + + // Verify notifications are deleted by checking count + notifCount, err := dynamoDB.GetNotificationCountForUser(userID, false) + require.NoError(t, err) + assert.Equal(t, int64(0), notifCount, "No unread notifications should exist") + + readNotifCount, err := dynamoDB.GetNotificationCountForUser(userID, true) + require.NoError(t, err) + assert.Equal(t, int64(0), readNotifCount, "No read notifications should exist") + + // Note: Full DynamoDB verification requires scanning, which is expensive + // In a real test, you might want to add specific checks for messages and timeline events +} + +func TestDeleteUser_NonExistentUser(t *testing.T) { + // Setup test environment + ctx := context.Background() + + // Ensure storage is available + testutils.EnsureStorage() + + // Extract dependencies + db := testutils.TestGormDb(t) + dynamoDB, err := dynamodb_storage.NewDB() + require.NoError(t, err, "Failed to create DynamoDB client") + + s3Client, err := s3.NewS3() + require.NoError(t, err, "Failed to create S3 client") + + // Create deletion service + deletionService := NewUserDeletionService(db, dynamoDB, s3Client) + + // Try to delete non-existent user + nonExistentUserID := uuid.NewString() + result, err := deletionService.DeleteUser(ctx, nonExistentUserID) + + // Should succeed with 0 deletions + require.NoError(t, err, "Deleting non-existent user should not error") + assert.Equal(t, 0, result.DeletedPostgresqlRecords, "Should delete 0 PostgreSQL records") + assert.Equal(t, 0, result.DeletedDynamoDBRecords, "Should delete 0 DynamoDB records") + assert.Equal(t, 0, result.DeletedS3Objects, "Should delete 0 S3 objects") + assert.Empty(t, result.Errors, "Should have no errors") +} + +func TestDeleteUser_PartialData(t *testing.T) { + // Setup test environment + ctx := context.Background() + + // Ensure storage is available + testutils.EnsureStorage() + + // Extract dependencies + db := testutils.TestGormDb(t) + dynamoDB, err := dynamodb_storage.NewDB() + require.NoError(t, err, "Failed to create DynamoDB client") + + s3Client, err := s3.NewS3() + require.NoError(t, err, "Failed to create S3 client") + + // Create deletion service + deletionService := NewUserDeletionService(db, dynamoDB, s3Client) + + // Create user with only PostgreSQL data (no DynamoDB/S3) + q := query.Use(db) + userID := uuid.NewString() + + user := &models.User{ + ID: userID, + Name: "Partial Test User", + Email: fmt.Sprintf("partial-%s@example.com", userID[:8]), + Provider: "test", + DisplayName: "Partial Test User", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + err = q.User.Create(user) + require.NoError(t, err, "Failed to create partial test user") + + // Delete user + result, err := deletionService.DeleteUser(ctx, userID) + + // Should succeed + require.NoError(t, err, "User deletion should succeed") + assert.Equal(t, 1, result.DeletedPostgresqlRecords, "Should delete 1 PostgreSQL record") + assert.Equal(t, 0, result.DeletedDynamoDBRecords, "Should delete 0 DynamoDB records") + assert.Equal(t, 0, result.DeletedS3Objects, "Should delete 0 S3 objects") + + // Verify user is deleted + count, err := q.User.Where(q.User.ID.Eq(userID)).Count() + require.NoError(t, err) + assert.Equal(t, int64(0), count, "User should be deleted") +}