The code and data behind xeiaso.net
5
fork

Configure Feed

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

feat(sponsor-panel): add Thoth token issuance card (#1196)

* feat(sponsor-panel): add thoth client

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(sponsor-panel): add Thoth token issuance card

Add a self-service dashboard card that lets $1+/month sponsors generate
Thoth API tokens. Uses lazy user creation: on first token generation,
creates a Thoth user via AdminUsers.Create and persists the ID on the
PanelUser model. Subsequent requests skip creation and go straight to
MakeJWT.

Includes empty email guard, HTMX double-click prevention, copy to
clipboard button, and instructional text for Anubis/Botstopper deployment.

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>

authored by

Xe Iaso and committed by
GitHub
a1243147 18e4ee4c

+555 -58
+1
.gitignore
··· 11 11 node_modules 12 12 /xesite 13 13 /xesitectl 14 + /sponsor-panel
+81
cmd/sponsor-panel/handlers.go
··· 16 16 "github.com/google/go-github/v82/github" 17 17 "github.com/google/uuid" 18 18 19 + adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1" 20 + 19 21 "xeiaso.net/v4/cmd/sponsor-panel/templates" 20 22 ) 21 23 ··· 318 320 func renderLogoSuccess(w http.ResponseWriter, company, issueURL string, issueNumber int) { 319 321 templates.LogoSuccess(company, issueURL, issueNumber).Render(context.Background(), w) 320 322 } 323 + 324 + // thothTokenHandler handles POST /thoth-token - issues a Thoth JWT for the user. 325 + func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) { 326 + if r.Method != http.MethodPost { 327 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 328 + return 329 + } 330 + 331 + slog.Debug("thothTokenHandler: processing token request") 332 + 333 + // Get user from session 334 + user, err := s.getSessionUser(r) 335 + if err != nil { 336 + slog.Error("thothTokenHandler: failed to get session user", "err", err) 337 + renderError(w, "Authentication required", http.StatusUnauthorized) 338 + return 339 + } 340 + 341 + slog.Debug("thothTokenHandler: authenticated user", "user_id", user.ID, "login", user.Login) 342 + 343 + // Check sponsorship tier (any active sponsorship) 344 + if !user.IsSponsorAtTier(100) { 345 + slog.Error("thothTokenHandler: user not a sponsor", "user", user.Login, "user_id", user.ID) 346 + renderError(w, "Requires active sponsorship", http.StatusForbidden) 347 + return 348 + } 349 + 350 + // Create Thoth user if not already provisioned 351 + if user.ThothUserID == nil { 352 + if user.Email == "" { 353 + slog.Error("thothTokenHandler: user has no email address", "user_id", user.ID, "login", user.Login) 354 + renderError(w, "Email address required. Please update your profile.", http.StatusBadRequest) 355 + return 356 + } 357 + 358 + slog.Debug("thothTokenHandler: creating Thoth user", "user_id", user.ID, "login", user.Login) 359 + 360 + resp, err := s.thothClient.AdminUsers.Create(r.Context(), &adminv1.UsersServiceCreateRequest{ 361 + EmailAddress: user.Email, 362 + Name: user.Login, 363 + CustomerId: user.Provider + ":" + user.Login, 364 + }) 365 + if err != nil { 366 + slog.Error("thothTokenHandler: failed to create Thoth user", "err", err, "user_id", user.ID) 367 + renderError(w, "Failed to create Thoth user: "+err.Error(), http.StatusInternalServerError) 368 + return 369 + } 370 + 371 + thothID := resp.GetUser().GetId() 372 + user.ThothUserID = &thothID 373 + 374 + if err := s.db.Save(user).Error; err != nil { 375 + slog.Error("thothTokenHandler: failed to save Thoth user ID", "err", err, "user_id", user.ID) 376 + renderError(w, "Failed to save Thoth user: "+err.Error(), http.StatusInternalServerError) 377 + return 378 + } 379 + 380 + slog.Info("thothTokenHandler: Thoth user created", "user_id", user.ID, "login", user.Login, "thoth_user_id", thothID) 381 + } 382 + 383 + // Issue JWT 384 + slog.Debug("thothTokenHandler: issuing JWT", "user_id", user.ID, "thoth_user_id", *user.ThothUserID) 385 + 386 + jwtResp, err := s.thothClient.AdminUsers.MakeJWT(r.Context(), &adminv1.UsersServiceMakeJWTRequest{ 387 + UserId: *user.ThothUserID, 388 + Comment: "sponsor-panel token for " + user.Login, 389 + }) 390 + if err != nil { 391 + slog.Error("thothTokenHandler: failed to issue JWT", "err", err, "user_id", user.ID) 392 + renderError(w, "Failed to issue token: "+err.Error(), http.StatusInternalServerError) 393 + return 394 + } 395 + 396 + slog.Info("thothTokenHandler: token issued", "user_id", user.ID, "login", user.Login) 397 + 398 + w.Header().Set("Content-Type", "text/html") 399 + w.WriteHeader(http.StatusOK) 400 + templates.ThothTokenSuccess(jwtResp.GetTokenInfo().GetJwt()).Render(context.Background(), w) 401 + }
+109
cmd/sponsor-panel/internal/thoth/thoth.go
··· 1 + package thoth 2 + 3 + import ( 4 + "context" 5 + "crypto/tls" 6 + "fmt" 7 + "time" 8 + 9 + grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus" 10 + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout" 11 + "github.com/prometheus/client_golang/prometheus" 12 + "google.golang.org/grpc" 13 + "google.golang.org/grpc/credentials" 14 + healthv1 "google.golang.org/grpc/health/grpc_health_v1" 15 + "google.golang.org/grpc/metadata" 16 + adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1" 17 + authv1 "xeiaso.net/v4/gen/techaro/thoth/auth/v1" 18 + ) 19 + 20 + type Client struct { 21 + conn *grpc.ClientConn 22 + 23 + Health healthv1.HealthClient 24 + AuthJWT authv1.JWTServiceClient 25 + AdminUsers adminv1.UsersServiceClient 26 + } 27 + 28 + func New(ctx context.Context, thothURL, apiToken string) (*Client, error) { 29 + clMetrics := grpcprom.NewClientMetrics( 30 + grpcprom.WithClientHandlingTimeHistogram( 31 + grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}), 32 + ), 33 + ) 34 + prometheus.DefaultRegisterer.Register(clMetrics) 35 + 36 + conn, err := grpc.NewClient( 37 + thothURL, 38 + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})), 39 + //grpc.WithTransportCredentials(insecure.NewCredentials()), 40 + grpc.WithChainUnaryInterceptor( 41 + timeout.UnaryClientInterceptor(5*time.Minute), 42 + clMetrics.UnaryClientInterceptor(), 43 + authUnaryClientInterceptor(apiToken), 44 + ), 45 + grpc.WithChainStreamInterceptor( 46 + clMetrics.StreamClientInterceptor(), 47 + authStreamClientInterceptor(apiToken), 48 + ), 49 + ) 50 + if err != nil { 51 + return nil, fmt.Errorf("can't dial thoth at %s: %w", thothURL, err) 52 + } 53 + 54 + hc := healthv1.NewHealthClient(conn) 55 + 56 + resp, err := hc.Check(ctx, &healthv1.HealthCheckRequest{}) 57 + if err != nil { 58 + return nil, fmt.Errorf("can't verify thoth health at %s: %w", thothURL, err) 59 + } 60 + 61 + if resp.Status != healthv1.HealthCheckResponse_SERVING { 62 + return nil, fmt.Errorf("thoth is not healthy, wanted %s but got %s", healthv1.HealthCheckResponse_SERVING, resp.Status) 63 + } 64 + 65 + return &Client{ 66 + conn: conn, 67 + Health: hc, 68 + AuthJWT: authv1.NewJWTServiceClient(conn), 69 + AdminUsers: adminv1.NewUsersServiceClient(conn), 70 + }, nil 71 + } 72 + 73 + func (c *Client) Close() error { 74 + if c.conn != nil { 75 + return c.conn.Close() 76 + } 77 + return nil 78 + } 79 + 80 + func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor { 81 + return func( 82 + ctx context.Context, 83 + method string, 84 + req interface{}, 85 + reply interface{}, 86 + cc *grpc.ClientConn, 87 + invoker grpc.UnaryInvoker, 88 + opts ...grpc.CallOption, 89 + ) error { 90 + md := metadata.Pairs("authorization", "Bearer "+token) 91 + ctx = metadata.NewOutgoingContext(ctx, md) 92 + return invoker(ctx, method, req, reply, cc, opts...) 93 + } 94 + } 95 + 96 + func authStreamClientInterceptor(token string) grpc.StreamClientInterceptor { 97 + return func( 98 + ctx context.Context, 99 + desc *grpc.StreamDesc, 100 + cc *grpc.ClientConn, 101 + method string, 102 + streamer grpc.Streamer, 103 + opts ...grpc.CallOption, 104 + ) (grpc.ClientStream, error) { 105 + md := metadata.Pairs("authorization", "Bearer "+token) 106 + ctx = metadata.NewOutgoingContext(ctx, md) 107 + return streamer(ctx, desc, cc, method, opts...) 108 + } 109 + }
+39 -23
cmd/sponsor-panel/main.go
··· 19 19 "github.com/facebookgo/flagenv" 20 20 gh "github.com/google/go-github/v82/github" 21 21 "github.com/gorilla/sessions" 22 + _ "github.com/joho/godotenv/autoload" 22 23 slogGorm "github.com/orandin/slog-gorm" 24 + "github.com/prometheus/client_golang/prometheus/promhttp" 25 + "golang.org/x/oauth2" 26 + "golang.org/x/oauth2/github" 27 + patreon "gopkg.in/mxpv/patreon-go.v1" 23 28 "gorm.io/driver/postgres" 24 29 "gorm.io/gorm" 25 30 gormPrometheus "gorm.io/plugin/prometheus" 26 - _ "github.com/joho/godotenv/autoload" 27 - patreon "gopkg.in/mxpv/patreon-go.v1" 28 - "github.com/prometheus/client_golang/prometheus/promhttp" 29 - "golang.org/x/oauth2" 30 - "golang.org/x/oauth2/github" 31 + "xeiaso.net/v4/cmd/sponsor-panel/internal/thoth" 31 32 "xeiaso.net/v4/internal" 32 33 "xeiaso.net/v4/web/htmx" 33 34 ) ··· 57 58 patreonCampaignID = flag.String("patreon-campaign-id", "", "Patreon campaign ID to check pledges against") 58 59 patreonFiftyPlus = flag.String("patreon-fifty-plus", "", "Comma-separated list of Patreon usernames always treated as $50+ sponsors") 59 60 61 + // Thoth settings 62 + thothToken = flag.String("thoth-token", "", "Thoth API token (use a god token)") 63 + thothURL = flag.String("thoth-url", "passthrough:///thoth.techaro.lol:443", "URL for the Thoth API server") 64 + 60 65 //go:embed static 61 66 staticFS embed.FS 62 67 ) 63 68 64 69 // Server holds the application dependencies. 65 70 type Server struct { 66 - db *gorm.DB 67 - ghClient *gh.Client 68 - oauth *oauth2.Config 71 + db *gorm.DB 72 + ghClient *gh.Client 73 + oauth *oauth2.Config 69 74 patreonOAuth *oauth2.Config // nil if Patreon not configured 70 75 patreonCampaignID string 71 76 patreonFiftyPlusSpons map[string]bool // Patreon usernames always treated as $50+ 72 - discordInvite string 73 - fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors 74 - sessionStore *sessions.CookieStore 75 - cookieSecure bool 76 - bucketName string 77 - s3Client *s3.Client 77 + discordInvite string 78 + fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors 79 + sessionStore *sessions.CookieStore 80 + cookieSecure bool 81 + bucketName string 82 + s3Client *s3.Client 83 + thothClient *thoth.Client 78 84 } 79 85 80 86 func main() { ··· 260 266 slog.Info("main: S3 client created", "bucket", *bucketName) 261 267 } 262 268 269 + thothClient, err := thoth.New(context.Background(), *thothURL, *thothToken) 270 + if err != nil { 271 + slog.Error("can't create thoth client", "err", err) 272 + os.Exit(2) 273 + } 274 + slog.Info("thoth client created") 275 + 263 276 server := &Server{ 264 - db: db, 265 - ghClient: ghClient, 266 - oauth: oauthConfig, 277 + db: db, 278 + ghClient: ghClient, 279 + oauth: oauthConfig, 267 280 patreonOAuth: patreonConfig, 268 281 patreonCampaignID: *patreonCampaignID, 269 282 patreonFiftyPlusSpons: patreonFiftyPlusMap, 270 - discordInvite: *discordInvite, 271 - fiftyPlusSponsors: fiftyPlusMap, 272 - sessionStore: sessionStore, 273 - cookieSecure: *cookieSecure, 274 - bucketName: *bucketName, 275 - s3Client: s3Client, 283 + discordInvite: *discordInvite, 284 + fiftyPlusSponsors: fiftyPlusMap, 285 + sessionStore: sessionStore, 286 + cookieSecure: *cookieSecure, 287 + bucketName: *bucketName, 288 + s3Client: s3Client, 289 + thothClient: thothClient, 276 290 } 277 291 278 292 mux := http.NewServeMux() ··· 308 322 // Feature handlers 309 323 mux.HandleFunc("/invite", server.inviteHandler) 310 324 mux.HandleFunc("/logo", server.logoHandler) 325 + mux.HandleFunc("/thoth-token", server.thothTokenHandler) 311 326 312 327 // Expose Prometheus metrics at /metrics for observability 313 328 mux.Handle("/metrics", promhttp.Handler()) ··· 322 337 "/", 323 338 "/invite", 324 339 "/logo", 340 + "/thoth-token", 325 341 "/metrics", 326 342 }) 327 343
+1
cmd/sponsor-panel/models.go
··· 18 18 AvatarURL string `json:"avatar_url"` 19 19 Name string `json:"name"` 20 20 Email string `json:"email"` 21 + ThothUserID *string `json:"thoth_user_id" gorm:"column:thoth_user_id"` 21 22 SponsorshipData string `json:"-" gorm:"type:jsonb"` 22 23 LastSponsorshipCheck time.Time `json:"last_sponsorship_check"` 23 24 CreatedAt time.Time `json:"created_at"`
+25
cmd/sponsor-panel/templates/dashboard.templ
··· 38 38 @LogoSubmitCard() 39 39 } 40 40 </div> 41 + <div class="grid md:grid-cols-2 gap-8 mt-8"> 42 + if props.IsSponsor { 43 + @ThothTokenCard() 44 + } 45 + </div> 41 46 </main> 42 47 } 43 48 ··· 174 179 <div id="logo-result"></div> 175 180 </div> 176 181 } 182 + 183 + templ ThothTokenCard() { 184 + <div class="card p-4"> 185 + <h2 class="card-title flex items-center gap-2"> 186 + <svg class="w-5 h-5 text-yellow-light dark:text-yellowDark-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 187 + <path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path> 188 + </svg> 189 + Thoth API Token 190 + </h2> 191 + <p class="card-description"> 192 + Generate an API token for Thoth services. 193 + </p> 194 + <form hx-post="/thoth-token" hx-target="#thoth-result"> 195 + <button type="submit" class="btn btn-dark w-full" hx-disabled-elt="this"> 196 + Generate Token 197 + </button> 198 + </form> 199 + <div id="thoth-result"></div> 200 + </div> 201 + }
+61 -22
cmd/sponsor-panel/templates/dashboard_templ.go
··· 93 93 return templ_7745c5c3_Err 94 94 } 95 95 } 96 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></main>") 96 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div><div class=\"grid md:grid-cols-2 gap-8 mt-8\">") 97 + if templ_7745c5c3_Err != nil { 98 + return templ_7745c5c3_Err 99 + } 100 + if props.IsSponsor { 101 + templ_7745c5c3_Err = ThothTokenCard().Render(ctx, templ_7745c5c3_Buffer) 102 + if templ_7745c5c3_Err != nil { 103 + return templ_7745c5c3_Err 104 + } 105 + } 106 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></main>") 97 107 if templ_7745c5c3_Err != nil { 98 108 return templ_7745c5c3_Err 99 109 } ··· 122 132 templ_7745c5c3_Var3 = templ.NopComponent 123 133 } 124 134 ctx = templ.ClearChildren(ctx) 125 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<nav class=\"navbar sticky top-0 z-50 px-8 py-5 md:px-12\"><div class=\"max-w-4xl mx-auto flex items-center justify-between\"><div class=\"flex items-center gap-3\"><img src=\"") 135 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<nav class=\"navbar sticky top-0 z-50 px-8 py-5 md:px-12\"><div class=\"max-w-4xl mx-auto flex items-center justify-between\"><div class=\"flex items-center gap-3\"><img src=\"") 126 136 if templ_7745c5c3_Err != nil { 127 137 return templ_7745c5c3_Err 128 138 } 129 139 var templ_7745c5c3_Var4 string 130 140 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.SafeURL(avatarURL)) 131 141 if templ_7745c5c3_Err != nil { 132 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 39} 142 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 53, Col: 39} 133 143 } 134 144 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 135 145 if templ_7745c5c3_Err != nil { 136 146 return templ_7745c5c3_Err 137 147 } 138 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"w-9 h-9 rounded-full ring-2 ring-bg-3 dark:ring-bgDark-3\" alt=\"\"> <span class=\"font-medium text-fg-1 dark:text-fgDark-1\">") 148 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"w-9 h-9 rounded-full ring-2 ring-bg-3 dark:ring-bgDark-3\" alt=\"\"> <span class=\"font-medium text-fg-1 dark:text-fgDark-1\">") 139 149 if templ_7745c5c3_Err != nil { 140 150 return templ_7745c5c3_Err 141 151 } 142 152 var templ_7745c5c3_Var5 string 143 153 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(login) 144 154 if templ_7745c5c3_Err != nil { 145 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 66} 155 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 54, Col: 66} 146 156 } 147 157 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 148 158 if templ_7745c5c3_Err != nil { 149 159 return templ_7745c5c3_Err 150 160 } 151 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></div><a href=\"/logout\" class=\"btn btn-ghost text-sm p-2\">Logout</a></div></nav>") 161 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></div><a href=\"/logout\" class=\"btn btn-ghost text-sm p-2\">Logout</a></div></nav>") 152 162 if templ_7745c5c3_Err != nil { 153 163 return templ_7745c5c3_Err 154 164 } ··· 177 187 templ_7745c5c3_Var6 = templ.NopComponent 178 188 } 179 189 ctx = templ.ClearChildren(ctx) 180 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"card card-cool p-4\"><h2 class=\"card-title flex items-center gap-3 !mb-4\"><svg class=\"w-6 h-6 text-blue-light dark:text-blueDark-light\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z\"></path></svg> Discord Community</h2><p class=\"card-description !mb-6\">Connect with other sponsors and get early access to updates.</p><a href=\"") 190 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"card card-cool p-4\"><h2 class=\"card-title flex items-center gap-3 !mb-4\"><svg class=\"w-6 h-6 text-blue-light dark:text-blueDark-light\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z\"></path></svg> Discord Community</h2><p class=\"card-description !mb-6\">Connect with other sponsors and get early access to updates.</p><a href=\"") 181 191 if templ_7745c5c3_Err != nil { 182 192 return templ_7745c5c3_Err 183 193 } 184 194 var templ_7745c5c3_Var7 templ.SafeURL 185 195 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(inviteURL)) 186 196 if templ_7745c5c3_Err != nil { 187 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 36} 197 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 74, Col: 36} 188 198 } 189 199 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 190 200 if templ_7745c5c3_Err != nil { 191 201 return templ_7745c5c3_Err 192 202 } 193 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" target=\"_blank\" class=\"btn btn-secondary p-2\">Join Discord</a></div>") 203 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" target=\"_blank\" class=\"btn btn-secondary p-2\">Join Discord</a></div>") 194 204 if templ_7745c5c3_Err != nil { 195 205 return templ_7745c5c3_Err 196 206 } ··· 219 229 templ_7745c5c3_Var8 = templ.NopComponent 220 230 } 221 231 ctx = templ.ClearChildren(ctx) 222 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"card card-warm p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-orange-light dark:text-orangeDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z\"></path></svg> Your Sponsorship</h2>") 232 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card card-warm p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-orange-light dark:text-orangeDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z\"></path></svg> Your Sponsorship</h2>") 223 233 if templ_7745c5c3_Err != nil { 224 234 return templ_7745c5c3_Err 225 235 } 226 236 if isSponsor { 227 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"flex items-center gap-2 mb-2\"><span class=\"accent-dot\"></span> <span class=\"text-lg font-semibold text-green-light dark:text-greenDark-light\">$") 237 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2 mb-2\"><span class=\"accent-dot\"></span> <span class=\"text-lg font-semibold text-green-light dark:text-greenDark-light\">$") 228 238 if templ_7745c5c3_Err != nil { 229 239 return templ_7745c5c3_Err 230 240 } 231 241 var templ_7745c5c3_Var9 string 232 242 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatDollars(amount)) 233 243 if templ_7745c5c3_Err != nil { 234 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 29} 244 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 92, Col: 29} 235 245 } 236 246 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 237 247 if templ_7745c5c3_Err != nil { 238 248 return templ_7745c5c3_Err 239 249 } 240 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "/month</span></div><p class=\"text-sm text-fg-3 dark:text-fgDark-3 mb-3\">") 250 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "/month</span></div><p class=\"text-sm text-fg-3 dark:text-fgDark-3 mb-3\">") 241 251 if templ_7745c5c3_Err != nil { 242 252 return templ_7745c5c3_Err 243 253 } 244 254 var templ_7745c5c3_Var10 string 245 255 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tier) 246 256 if templ_7745c5c3_Err != nil { 247 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 62} 257 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 62} 248 258 } 249 259 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 250 260 if templ_7745c5c3_Err != nil { 251 261 return templ_7745c5c3_Err 252 262 } 253 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p><p class=\"text-sm font-medium text-orange-light dark:text-orangeDark-light\">Thank you for your support!</p>") 263 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p><p class=\"text-sm font-medium text-orange-light dark:text-orangeDark-light\">Thank you for your support!</p>") 254 264 if templ_7745c5c3_Err != nil { 255 265 return templ_7745c5c3_Err 256 266 } 257 267 } else { 258 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"card-description\">You're not currently an active sponsor.</p>") 268 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"card-description\">You're not currently an active sponsor.</p>") 259 269 if templ_7745c5c3_Err != nil { 260 270 return templ_7745c5c3_Err 261 271 } 262 272 if provider == "patreon" { 263 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"https://www.patreon.com/cadey\" target=\"_blank\" class=\"btn btn-pink\">Become a Patron</a>") 273 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"https://www.patreon.com/cadey\" target=\"_blank\" class=\"btn btn-pink\">Become a Patron</a>") 264 274 if templ_7745c5c3_Err != nil { 265 275 return templ_7745c5c3_Err 266 276 } 267 277 } else { 268 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"https://github.com/sponsors/Xe\" target=\"_blank\" class=\"btn btn-pink\">Become a Sponsor</a>") 278 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"https://github.com/sponsors/Xe\" target=\"_blank\" class=\"btn btn-pink\">Become a Sponsor</a>") 269 279 if templ_7745c5c3_Err != nil { 270 280 return templ_7745c5c3_Err 271 281 } 272 282 } 273 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " <p class=\"text-xs text-fg-4 dark:text-fgDark-4 mt-4 leading-relaxed\">If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.</p>") 283 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " <p class=\"text-xs text-fg-4 dark:text-fgDark-4 mt-4 leading-relaxed\">If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.</p>") 274 284 if templ_7745c5c3_Err != nil { 275 285 return templ_7745c5c3_Err 276 286 } 277 287 } 278 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>") 288 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>") 279 289 if templ_7745c5c3_Err != nil { 280 290 return templ_7745c5c3_Err 281 291 } ··· 304 314 templ_7745c5c3_Var11 = templ.NopComponent 305 315 } 306 316 ctx = templ.ClearChildren(ctx) 307 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"card card-green p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-green-light dark:text-greenDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z\"></path></svg> Team Invitation</h2><p class=\"card-description\">Invite team members to TecharoHQ as part of your sponsorship.</p><form hx-post=\"/invite\" hx-target=\"#invite-result\" class=\"space-y-3\"><input type=\"text\" name=\"username\" placeholder=\"GitHub username\" required class=\"input\"> <button type=\"submit\" class=\"btn btn-primary w-full\">Send Invitation</button></form><div id=\"invite-result\"></div></div>") 317 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card card-green p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-green-light dark:text-greenDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z\"></path></svg> Team Invitation</h2><p class=\"card-description\">Invite team members to TecharoHQ as part of your sponsorship.</p><form hx-post=\"/invite\" hx-target=\"#invite-result\" class=\"space-y-3\"><input type=\"text\" name=\"username\" placeholder=\"GitHub username\" required class=\"input\"> <button type=\"submit\" class=\"btn btn-primary w-full\">Send Invitation</button></form><div id=\"invite-result\"></div></div>") 308 318 if templ_7745c5c3_Err != nil { 309 319 return templ_7745c5c3_Err 310 320 } ··· 333 343 templ_7745c5c3_Var12 = templ.NopComponent 334 344 } 335 345 ctx = templ.ClearChildren(ctx) 336 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-purple-light dark:text-purpleDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"></path></svg> Logo Submission</h2><p class=\"card-description\">Submit your company logo for the Anubis README.</p><form hx-post=\"/logo\" hx-encoding=\"multipart/form-data\" hx-target=\"#logo-result\" class=\"space-y-3\"><input type=\"text\" name=\"company\" placeholder=\"Company Name\" required class=\"input\"> <input type=\"url\" name=\"website\" placeholder=\"Website URL\" required class=\"input\"> <input type=\"file\" name=\"logo\" accept=\"image/png,image/jpeg,image/svg+xml\" required class=\"file-input\"> <button type=\"submit\" class=\"btn btn-dark w-full\">Submit Logo</button></form><div id=\"logo-result\"></div></div>") 346 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-purple-light dark:text-purpleDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"></path></svg> Logo Submission</h2><p class=\"card-description\">Submit your company logo for the Anubis README.</p><form hx-post=\"/logo\" hx-encoding=\"multipart/form-data\" hx-target=\"#logo-result\" class=\"space-y-3\"><input type=\"text\" name=\"company\" placeholder=\"Company Name\" required class=\"input\"> <input type=\"url\" name=\"website\" placeholder=\"Website URL\" required class=\"input\"> <input type=\"file\" name=\"logo\" accept=\"image/png,image/jpeg,image/svg+xml\" required class=\"file-input\"> <button type=\"submit\" class=\"btn btn-dark w-full\">Submit Logo</button></form><div id=\"logo-result\"></div></div>") 347 + if templ_7745c5c3_Err != nil { 348 + return templ_7745c5c3_Err 349 + } 350 + return nil 351 + }) 352 + } 353 + 354 + func ThothTokenCard() templ.Component { 355 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 356 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 357 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 358 + return templ_7745c5c3_CtxErr 359 + } 360 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 361 + if !templ_7745c5c3_IsBuffer { 362 + defer func() { 363 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 364 + if templ_7745c5c3_Err == nil { 365 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 366 + } 367 + }() 368 + } 369 + ctx = templ.InitializeContext(ctx) 370 + templ_7745c5c3_Var13 := templ.GetChildren(ctx) 371 + if templ_7745c5c3_Var13 == nil { 372 + templ_7745c5c3_Var13 = templ.NopComponent 373 + } 374 + ctx = templ.ClearChildren(ctx) 375 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"card p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-yellow-light dark:text-yellowDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z\"></path></svg> Thoth API Token</h2><p class=\"card-description\">Generate an API token for Thoth services.</p><form hx-post=\"/thoth-token\" hx-target=\"#thoth-result\"><button type=\"submit\" class=\"btn btn-dark w-full\" hx-disabled-elt=\"this\">Generate Token</button></form><div id=\"thoth-result\"></div></div>") 337 376 if templ_7745c5c3_Err != nil { 338 377 return templ_7745c5c3_Err 339 378 }
+39
cmd/sponsor-panel/templates/formsuccess.templ
··· 41 41 </div> 42 42 </div> 43 43 } 44 + 45 + // ThothTokenSuccess renders the success response for Thoth token generation. 46 + templ ThothTokenSuccess(token string) { 47 + <div class="alert-success mt-3"> 48 + <div class="flex items-start gap-3"> 49 + <svg class="w-5 h-5 text-green-light dark:text-greenDark-light flex-shrink-0 mt-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 50 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> 51 + </svg> 52 + <div class="w-full"> 53 + <p class="text-sm text-green-dark dark:text-greenDark-dark font-semibold">Token generated!</p> 54 + <p class="text-sm text-fg-3 dark:text-fgDark-3 mt-1"> 55 + Set these environment variables in your Anubis/Botstopper deployment. 56 + </p> 57 + <div class="relative mt-2"> 58 + <pre class="p-3 pr-12 bg-bg-2 dark:bg-bgDark-2 rounded text-xs font-mono overflow-x-auto whitespace-pre" id="thoth-token-output">{ "THOTH_URL=passthrough:///thoth.techaro.lol:443\nTHOTH_TOKEN=" + token }</pre> 59 + <button 60 + type="button" 61 + class="absolute top-2 right-2 p-1.5 rounded bg-bg-3 dark:bg-bgDark-3 hover:bg-bg-4 dark:hover:bg-bgDark-4 transition-colors" 62 + onclick=" 63 + var text = document.getElementById('thoth-token-output').textContent; 64 + navigator.clipboard.writeText(text).then(function() { 65 + var btn = event.currentTarget; 66 + btn.innerHTML = '<svg class=&quot;w-4 h-4 text-green-light dark:text-greenDark-light&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;><path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M5 13l4 4L19 7&quot;/></svg>'; 67 + setTimeout(function() { 68 + btn.innerHTML = '<svg class=&quot;w-4 h-4 text-fg-3 dark:text-fgDark-3&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;><path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z&quot;/></svg>'; 69 + }, 2000); 70 + }); 71 + " 72 + title="Copy to clipboard" 73 + > 74 + <svg class="w-4 h-4 text-fg-3 dark:text-fgDark-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 75 + <path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/> 76 + </svg> 77 + </button> 78 + </div> 79 + </div> 80 + </div> 81 + </div> 82 + }
+43
cmd/sponsor-panel/templates/formsuccess_templ.go
··· 133 133 }) 134 134 } 135 135 136 + // ThothTokenSuccess renders the success response for Thoth token generation. 137 + func ThothTokenSuccess(token string) templ.Component { 138 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 139 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 140 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 141 + return templ_7745c5c3_CtxErr 142 + } 143 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 144 + if !templ_7745c5c3_IsBuffer { 145 + defer func() { 146 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 147 + if templ_7745c5c3_Err == nil { 148 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 149 + } 150 + }() 151 + } 152 + ctx = templ.InitializeContext(ctx) 153 + templ_7745c5c3_Var8 := templ.GetChildren(ctx) 154 + if templ_7745c5c3_Var8 == nil { 155 + templ_7745c5c3_Var8 = templ.NopComponent 156 + } 157 + ctx = templ.ClearChildren(ctx) 158 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"alert-success mt-3\"><div class=\"flex items-start gap-3\"><svg class=\"w-5 h-5 text-green-light dark:text-greenDark-light flex-shrink-0 mt-0.5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"></path></svg><div class=\"w-full\"><p class=\"text-sm text-green-dark dark:text-greenDark-dark font-semibold\">Token generated!</p><p class=\"text-sm text-fg-3 dark:text-fgDark-3 mt-1\">Set these environment variables in your Anubis/Botstopper deployment.</p><div class=\"relative mt-2\"><pre class=\"p-3 pr-12 bg-bg-2 dark:bg-bgDark-2 rounded text-xs font-mono overflow-x-auto whitespace-pre\" id=\"thoth-token-output\">") 159 + if templ_7745c5c3_Err != nil { 160 + return templ_7745c5c3_Err 161 + } 162 + var templ_7745c5c3_Var9 string 163 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("THOTH_URL=passthrough:///thoth.techaro.lol:443\nTHOTH_TOKEN=" + token) 164 + if templ_7745c5c3_Err != nil { 165 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 58, Col: 206} 166 + } 167 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 168 + if templ_7745c5c3_Err != nil { 169 + return templ_7745c5c3_Err 170 + } 171 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</pre><button type=\"button\" class=\"absolute top-2 right-2 p-1.5 rounded bg-bg-3 dark:bg-bgDark-3 hover:bg-bg-4 dark:hover:bg-bgDark-4 transition-colors\" onclick=\"\n\t\t\t\t\t\t\tvar text = document.getElementById('thoth-token-output').textContent;\n\t\t\t\t\t\t\tnavigator.clipboard.writeText(text).then(function() {\n\t\t\t\t\t\t\t\tvar btn = event.currentTarget;\n\t\t\t\t\t\t\t\tbtn.innerHTML = '<svg class=&quot;w-4 h-4 text-green-light dark:text-greenDark-light&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;><path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M5 13l4 4L19 7&quot;/></svg>';\n\t\t\t\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\t\t\t\tbtn.innerHTML = '<svg class=&quot;w-4 h-4 text-fg-3 dark:text-fgDark-3&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;><path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z&quot;/></svg>';\n\t\t\t\t\t\t\t\t}, 2000);\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\" title=\"Copy to clipboard\"><svg class=\"w-4 h-4 text-fg-3 dark:text-fgDark-3\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"></path></svg></button></div></div></div></div>") 172 + if templ_7745c5c3_Err != nil { 173 + return templ_7745c5c3_Err 174 + } 175 + return nil 176 + }) 177 + } 178 + 136 179 var _ = templruntime.GeneratedTemplate
+142
docs/superpowers/plans/2026-04-01-thoth-token-issuance-design.md
··· 1 + # Thoth Token Issuance Card 2 + 3 + ## Context 4 + 5 + Sponsors need Thoth API tokens to access Thoth services. Currently there is no 6 + self-service way to get these tokens -- they must be manually provisioned. This 7 + feature adds a card to the sponsor panel dashboard that lets any $1+/month 8 + sponsor generate a Thoth token with one click. 9 + 10 + The Thoth gRPC client is already wired into the sponsor panel (`server.thothClient`) 11 + with `AdminUsers` and `AuthJWT` service clients. The missing pieces are: a 12 + `ThothUserID` field on the user model, a handler to orchestrate user creation 13 + and JWT minting, a template card, and a success response template. 14 + 15 + ## Approach: Lazy Thoth User Creation 16 + 17 + Create the Thoth user on-demand when the sponsor first generates a token. Store 18 + the Thoth user ID on the `PanelUser` model. Subsequent requests skip creation 19 + and go straight to `MakeJWT`. 20 + 21 + ## Changes 22 + 23 + ### 1. Data Model (`models.go`) 24 + 25 + Add field to `PanelUser`: 26 + 27 + ```go 28 + ThothUserID *string `json:"thoth_user_id" gorm:"column:thoth_user_id"` 29 + ``` 30 + 31 + Nullable -- existing users get NULL. GORM AutoMigrate adds the column on next 32 + startup. 33 + 34 + ### 2. Handler (`handlers.go`) 35 + 36 + New function: `func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request)` 37 + 38 + Flow: 39 + 40 + 1. Require POST method 41 + 2. Get session user via `s.getSessionUser(r)` 42 + 3. Check `user.IsSponsorAtTier(100)` (any $1+ sponsor) 43 + 4. If `user.ThothUserID == nil`: 44 + - Call `s.thothClient.AdminUsers.Create(ctx, &adminv1.UsersServiceCreateRequest{...})` 45 + - `EmailAddress`: `user.Email` 46 + - `Name`: `user.Login` 47 + - `CustomerId`: `user.Provider + ":" + user.Login` 48 + - Save `resp.User.Id` to `user.ThothUserID` in the database 49 + 5. Call `s.thothClient.AdminUsers.MakeJWT(ctx, &adminv1.UsersServiceMakeJWTRequest{...})` 50 + - `UserId`: `*user.ThothUserID` 51 + - `Comment`: `"sponsor-panel token for " + user.Login` 52 + 6. Render `templates.ThothTokenSuccess(resp.TokenInfo.Jwt)` on success 53 + 7. Use `renderError()` for all error paths (same pattern as other handlers) 54 + 55 + ### 3. Templates (`templates/dashboard.templ`) 56 + 57 + New card component: 58 + 59 + ``` 60 + templ ThothTokenCard() 61 + ``` 62 + 63 + - Default card style (no color variant, like LogoSubmitCard) 64 + - Key/token icon in header 65 + - Title: "Thoth API Token" 66 + - Description: "Generate an API token for Thoth services." 67 + - Single button: "Generate Token" 68 + - `hx-post="/thoth-token"` `hx-target="#thoth-result"` 69 + - Result div: `<div id="thoth-result"></div>` 70 + 71 + New success component (in `formresult.templ` or a new file): 72 + 73 + ``` 74 + templ ThothTokenSuccess(token string) 75 + ``` 76 + 77 + - Renders inside an `alert-success` div 78 + - Contains a `<pre>` block with: 79 + ``` 80 + THOTH_URL=passthrough:///thoth.techaro.lol:443 81 + THOTH_TOKEN=<token> 82 + ``` 83 + - Monospace styling so users can copy-paste 84 + 85 + ### 4. Dashboard Layout (`templates/dashboard.templ`) 86 + 87 + Add `ThothTokenCard()` to the second row grid, shown when `props.IsSponsor` is 88 + true: 89 + 90 + ```templ 91 + <div class="grid md:grid-cols-2 gap-8 mt-8"> 92 + if props.IsFiftyPlus { 93 + @TeamInviteCard() 94 + } 95 + if props.IsSponsor { 96 + @LogoSubmitCard() 97 + } 98 + if props.IsSponsor { 99 + @ThothTokenCard() 100 + } 101 + </div> 102 + ``` 103 + 104 + If a $50+ sponsor sees all three cards (TeamInvite, LogoSubmit, ThothToken), 105 + add a third row for ThothTokenCard to keep the 2-column grid clean. 106 + 107 + ### 5. Routing (`main.go`) 108 + 109 + Add route: 110 + 111 + ```go 112 + mux.HandleFunc("/thoth-token", server.thothTokenHandler) 113 + ``` 114 + 115 + Add to the debug routes list. 116 + 117 + ## Files Modified 118 + 119 + - `cmd/sponsor-panel/models.go` -- add ThothUserID field to PanelUser 120 + - `cmd/sponsor-panel/handlers.go` -- add thothTokenHandler + renderThothSuccess 121 + - `cmd/sponsor-panel/templates/dashboard.templ` -- add ThothTokenCard, wire into layout 122 + - `cmd/sponsor-panel/templates/formresult.templ` -- add ThothTokenSuccess component 123 + - `cmd/sponsor-panel/main.go` -- add /thoth-token route 124 + 125 + ## Existing Code to Reuse 126 + 127 + - `server.getSessionUser(r)` -- session auth (`oauth.go:634`) 128 + - `user.IsSponsorAtTier(100)` -- tier check (`models.go:38`) 129 + - `renderError(w, msg, code)` -- error rendering (`handlers.go:302`) 130 + - `templates.FormResult(msg, bool)` -- generic result component (`formresult.templ`) 131 + - `server.thothClient.AdminUsers` -- gRPC client (`internal/thoth/thoth.go`) 132 + - Generated proto types from `xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1` 133 + 134 + ## Verification 135 + 136 + 1. `go build ./cmd/sponsor-panel` -- compiles without errors 137 + 2. `npm test` -- all tests pass 138 + 3. `npm run dev:sponsor-panel` -- start dev server 139 + 4. Log in as a sponsor, verify the Thoth card appears 140 + 5. Click "Generate Token", verify credentials are displayed 141 + 6. Click again, verify it works without creating a duplicate Thoth user 142 + 7. Check database: `ThothUserID` column populated after first generation
+2
go.mod
··· 17 17 github.com/google/subcommands v1.2.0 18 18 github.com/google/uuid v1.6.0 19 19 github.com/gorilla/sessions v1.4.0 20 + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 21 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 20 22 github.com/jackc/pgx/v5 v5.9.1 21 23 github.com/joho/godotenv v1.5.1 22 24 github.com/orandin/slog-gorm v1.4.0
+4
go.sum
··· 377 377 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 378 378 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 379 379 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 380 + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= 381 + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= 382 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= 383 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= 380 384 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 381 385 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 382 386 github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
+8 -13
web/htmx/htmx_templ.go
··· 1 1 // Code generated by templ - DO NOT EDIT. 2 2 3 - // templ: version: v0.3.1001 3 + // templ: version: v0.2.731 4 4 package htmx 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. ··· 21 21 func Use(exts ...string) templ.Component { 22 22 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 23 23 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 24 - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 25 - return templ_7745c5c3_CtxErr 26 - } 27 24 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 28 25 if !templ_7745c5c3_IsBuffer { 29 26 defer func() { ··· 39 36 templ_7745c5c3_Var1 = templ.NopComponent 40 37 } 41 38 ctx = templ.ClearChildren(ctx) 42 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script src=\"") 39 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<script src=\"") 43 40 if templ_7745c5c3_Err != nil { 44 41 return templ_7745c5c3_Err 45 42 } 46 43 var templ_7745c5c3_Var2 string 47 44 templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(URL + "htmx.js") 48 45 if templ_7745c5c3_Err != nil { 49 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `htmx.templ`, Line: 14, Col: 30} 46 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/htmx/htmx.templ`, Line: 14, Col: 30} 50 47 } 51 48 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 52 49 if templ_7745c5c3_Err != nil { 53 50 return templ_7745c5c3_Err 54 51 } 55 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></script>") 52 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></script>") 56 53 if templ_7745c5c3_Err != nil { 57 54 return templ_7745c5c3_Err 58 55 } 59 56 for _, ext := range exts { 60 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<script src=\"") 57 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<script src=\"") 61 58 if templ_7745c5c3_Err != nil { 62 59 return templ_7745c5c3_Err 63 60 } 64 61 var templ_7745c5c3_Var3 string 65 62 templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(URL + ext + ".js") 66 63 if templ_7745c5c3_Err != nil { 67 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `htmx.templ`, Line: 16, Col: 33} 64 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/htmx/htmx.templ`, Line: 16, Col: 33} 68 65 } 69 66 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 70 67 if templ_7745c5c3_Err != nil { 71 68 return templ_7745c5c3_Err 72 69 } 73 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></script>") 70 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></script>") 74 71 if templ_7745c5c3_Err != nil { 75 72 return templ_7745c5c3_Err 76 73 } 77 74 } 78 - return nil 75 + return templ_7745c5c3_Err 79 76 }) 80 77 } 81 - 82 - var _ = templruntime.GeneratedTemplate