diff --git a/.gitignore b/.gitignore index 8a74e5c88..c972fe993 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ cw.tar node_modules /xesite /xesitectl +/sponsor-panel diff --git a/cmd/sponsor-panel/handlers.go b/cmd/sponsor-panel/handlers.go index 7125f12f2..b5020ad68 100644 --- a/cmd/sponsor-panel/handlers.go +++ b/cmd/sponsor-panel/handlers.go @@ -16,6 +16,8 @@ import ( "github.com/google/go-github/v82/github" "github.com/google/uuid" + adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1" + "xeiaso.net/v4/cmd/sponsor-panel/templates" ) @@ -318,3 +320,82 @@ func renderInviteSuccess(w http.ResponseWriter, username, state string) { func renderLogoSuccess(w http.ResponseWriter, company, issueURL string, issueNumber int) { templates.LogoSuccess(company, issueURL, issueNumber).Render(context.Background(), w) } + +// thothTokenHandler handles POST /thoth-token - issues a Thoth JWT for the user. +func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + slog.Debug("thothTokenHandler: processing token request") + + // Get user from session + user, err := s.getSessionUser(r) + if err != nil { + slog.Error("thothTokenHandler: failed to get session user", "err", err) + renderError(w, "Authentication required", http.StatusUnauthorized) + return + } + + slog.Debug("thothTokenHandler: authenticated user", "user_id", user.ID, "login", user.Login) + + // Check sponsorship tier (any active sponsorship) + if !user.IsSponsorAtTier(100) { + slog.Error("thothTokenHandler: user not a sponsor", "user", user.Login, "user_id", user.ID) + renderError(w, "Requires active sponsorship", http.StatusForbidden) + return + } + + // Create Thoth user if not already provisioned + if user.ThothUserID == nil { + if user.Email == "" { + slog.Error("thothTokenHandler: user has no email address", "user_id", user.ID, "login", user.Login) + renderError(w, "Email address required. Please update your profile.", http.StatusBadRequest) + return + } + + slog.Debug("thothTokenHandler: creating Thoth user", "user_id", user.ID, "login", user.Login) + + resp, err := s.thothClient.AdminUsers.Create(r.Context(), &adminv1.UsersServiceCreateRequest{ + EmailAddress: user.Email, + Name: user.Login, + CustomerId: user.Provider + ":" + user.Login, + }) + if err != nil { + slog.Error("thothTokenHandler: failed to create Thoth user", "err", err, "user_id", user.ID) + renderError(w, "Failed to create Thoth user: "+err.Error(), http.StatusInternalServerError) + return + } + + thothID := resp.GetUser().GetId() + user.ThothUserID = &thothID + + if err := s.db.Save(user).Error; err != nil { + slog.Error("thothTokenHandler: failed to save Thoth user ID", "err", err, "user_id", user.ID) + renderError(w, "Failed to save Thoth user: "+err.Error(), http.StatusInternalServerError) + return + } + + slog.Info("thothTokenHandler: Thoth user created", "user_id", user.ID, "login", user.Login, "thoth_user_id", thothID) + } + + // Issue JWT + slog.Debug("thothTokenHandler: issuing JWT", "user_id", user.ID, "thoth_user_id", *user.ThothUserID) + + jwtResp, err := s.thothClient.AdminUsers.MakeJWT(r.Context(), &adminv1.UsersServiceMakeJWTRequest{ + UserId: *user.ThothUserID, + Comment: "sponsor-panel token for " + user.Login, + }) + if err != nil { + slog.Error("thothTokenHandler: failed to issue JWT", "err", err, "user_id", user.ID) + renderError(w, "Failed to issue token: "+err.Error(), http.StatusInternalServerError) + return + } + + slog.Info("thothTokenHandler: token issued", "user_id", user.ID, "login", user.Login) + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + templates.ThothTokenSuccess(jwtResp.GetTokenInfo().GetJwt()).Render(context.Background(), w) +} diff --git a/cmd/sponsor-panel/internal/thoth/thoth.go b/cmd/sponsor-panel/internal/thoth/thoth.go new file mode 100644 index 000000000..1fee896e7 --- /dev/null +++ b/cmd/sponsor-panel/internal/thoth/thoth.go @@ -0,0 +1,109 @@ +package thoth + +import ( + "context" + "crypto/tls" + "fmt" + "time" + + grpcprom "github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus" + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/timeout" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + healthv1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + adminv1 "xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1" + authv1 "xeiaso.net/v4/gen/techaro/thoth/auth/v1" +) + +type Client struct { + conn *grpc.ClientConn + + Health healthv1.HealthClient + AuthJWT authv1.JWTServiceClient + AdminUsers adminv1.UsersServiceClient +} + +func New(ctx context.Context, thothURL, apiToken string) (*Client, error) { + clMetrics := grpcprom.NewClientMetrics( + grpcprom.WithClientHandlingTimeHistogram( + grpcprom.WithHistogramBuckets([]float64{0.001, 0.01, 0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}), + ), + ) + prometheus.DefaultRegisterer.Register(clMetrics) + + conn, err := grpc.NewClient( + thothURL, + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})), + //grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithChainUnaryInterceptor( + timeout.UnaryClientInterceptor(5*time.Minute), + clMetrics.UnaryClientInterceptor(), + authUnaryClientInterceptor(apiToken), + ), + grpc.WithChainStreamInterceptor( + clMetrics.StreamClientInterceptor(), + authStreamClientInterceptor(apiToken), + ), + ) + if err != nil { + return nil, fmt.Errorf("can't dial thoth at %s: %w", thothURL, err) + } + + hc := healthv1.NewHealthClient(conn) + + resp, err := hc.Check(ctx, &healthv1.HealthCheckRequest{}) + if err != nil { + return nil, fmt.Errorf("can't verify thoth health at %s: %w", thothURL, err) + } + + if resp.Status != healthv1.HealthCheckResponse_SERVING { + return nil, fmt.Errorf("thoth is not healthy, wanted %s but got %s", healthv1.HealthCheckResponse_SERVING, resp.Status) + } + + return &Client{ + conn: conn, + Health: hc, + AuthJWT: authv1.NewJWTServiceClient(conn), + AdminUsers: adminv1.NewUsersServiceClient(conn), + }, nil +} + +func (c *Client) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +func authUnaryClientInterceptor(token string) grpc.UnaryClientInterceptor { + return func( + ctx context.Context, + method string, + req interface{}, + reply interface{}, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + return invoker(ctx, method, req, reply, cc, opts...) + } +} + +func authStreamClientInterceptor(token string) grpc.StreamClientInterceptor { + return func( + ctx context.Context, + desc *grpc.StreamDesc, + cc *grpc.ClientConn, + method string, + streamer grpc.Streamer, + opts ...grpc.CallOption, + ) (grpc.ClientStream, error) { + md := metadata.Pairs("authorization", "Bearer "+token) + ctx = metadata.NewOutgoingContext(ctx, md) + return streamer(ctx, desc, cc, method, opts...) + } +} diff --git a/cmd/sponsor-panel/main.go b/cmd/sponsor-panel/main.go index bab435cee..086d92ff0 100644 --- a/cmd/sponsor-panel/main.go +++ b/cmd/sponsor-panel/main.go @@ -19,15 +19,16 @@ import ( "github.com/facebookgo/flagenv" gh "github.com/google/go-github/v82/github" "github.com/gorilla/sessions" - slogGorm "github.com/orandin/slog-gorm" - "gorm.io/driver/postgres" - "gorm.io/gorm" - gormPrometheus "gorm.io/plugin/prometheus" _ "github.com/joho/godotenv/autoload" - patreon "gopkg.in/mxpv/patreon-go.v1" + slogGorm "github.com/orandin/slog-gorm" "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/oauth2" "golang.org/x/oauth2/github" + patreon "gopkg.in/mxpv/patreon-go.v1" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormPrometheus "gorm.io/plugin/prometheus" + "xeiaso.net/v4/cmd/sponsor-panel/internal/thoth" "xeiaso.net/v4/internal" "xeiaso.net/v4/web/htmx" ) @@ -57,24 +58,29 @@ var ( patreonCampaignID = flag.String("patreon-campaign-id", "", "Patreon campaign ID to check pledges against") patreonFiftyPlus = flag.String("patreon-fifty-plus", "", "Comma-separated list of Patreon usernames always treated as $50+ sponsors") + // Thoth settings + thothToken = flag.String("thoth-token", "", "Thoth API token (use a god token)") + thothURL = flag.String("thoth-url", "passthrough:///thoth.techaro.lol:443", "URL for the Thoth API server") + //go:embed static staticFS embed.FS ) // Server holds the application dependencies. type Server struct { - db *gorm.DB - ghClient *gh.Client - oauth *oauth2.Config + db *gorm.DB + ghClient *gh.Client + oauth *oauth2.Config patreonOAuth *oauth2.Config // nil if Patreon not configured patreonCampaignID string patreonFiftyPlusSpons map[string]bool // Patreon usernames always treated as $50+ - discordInvite string - fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors - sessionStore *sessions.CookieStore - cookieSecure bool - bucketName string - s3Client *s3.Client + discordInvite string + fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors + sessionStore *sessions.CookieStore + cookieSecure bool + bucketName string + s3Client *s3.Client + thothClient *thoth.Client } func main() { @@ -260,19 +266,27 @@ func main() { slog.Info("main: S3 client created", "bucket", *bucketName) } + thothClient, err := thoth.New(context.Background(), *thothURL, *thothToken) + if err != nil { + slog.Error("can't create thoth client", "err", err) + os.Exit(2) + } + slog.Info("thoth client created") + server := &Server{ - db: db, - ghClient: ghClient, - oauth: oauthConfig, + db: db, + ghClient: ghClient, + oauth: oauthConfig, patreonOAuth: patreonConfig, patreonCampaignID: *patreonCampaignID, patreonFiftyPlusSpons: patreonFiftyPlusMap, - discordInvite: *discordInvite, - fiftyPlusSponsors: fiftyPlusMap, - sessionStore: sessionStore, - cookieSecure: *cookieSecure, - bucketName: *bucketName, - s3Client: s3Client, + discordInvite: *discordInvite, + fiftyPlusSponsors: fiftyPlusMap, + sessionStore: sessionStore, + cookieSecure: *cookieSecure, + bucketName: *bucketName, + s3Client: s3Client, + thothClient: thothClient, } mux := http.NewServeMux() @@ -308,6 +322,7 @@ func main() { // Feature handlers mux.HandleFunc("/invite", server.inviteHandler) mux.HandleFunc("/logo", server.logoHandler) + mux.HandleFunc("/thoth-token", server.thothTokenHandler) // Expose Prometheus metrics at /metrics for observability mux.Handle("/metrics", promhttp.Handler()) @@ -322,6 +337,7 @@ func main() { "/", "/invite", "/logo", + "/thoth-token", "/metrics", }) diff --git a/cmd/sponsor-panel/models.go b/cmd/sponsor-panel/models.go index 22cafe205..adaa5a194 100644 --- a/cmd/sponsor-panel/models.go +++ b/cmd/sponsor-panel/models.go @@ -18,6 +18,7 @@ type PanelUser struct { AvatarURL string `json:"avatar_url"` Name string `json:"name"` Email string `json:"email"` + ThothUserID *string `json:"thoth_user_id" gorm:"column:thoth_user_id"` SponsorshipData string `json:"-" gorm:"type:jsonb"` LastSponsorshipCheck time.Time `json:"last_sponsorship_check"` CreatedAt time.Time `json:"created_at"` diff --git a/cmd/sponsor-panel/templates/dashboard.templ b/cmd/sponsor-panel/templates/dashboard.templ index 852eb0255..d7050f82c 100644 --- a/cmd/sponsor-panel/templates/dashboard.templ +++ b/cmd/sponsor-panel/templates/dashboard.templ @@ -38,6 +38,11 @@ templ Dashboard(props DashboardProps) { @LogoSubmitCard() } +
+ if props.IsSponsor { + @ThothTokenCard() + } +
} @@ -174,3 +179,23 @@ templ LogoSubmitCard() {
} + +templ ThothTokenCard() { +
+

+ + + + Thoth API Token +

+

+ Generate an API token for Thoth services. +

+
+ +
+
+
+} diff --git a/cmd/sponsor-panel/templates/dashboard_templ.go b/cmd/sponsor-panel/templates/dashboard_templ.go index a2a17f39e..be19ddbae 100644 --- a/cmd/sponsor-panel/templates/dashboard_templ.go +++ b/cmd/sponsor-panel/templates/dashboard_templ.go @@ -93,7 +93,17 @@ func Dashboard(props DashboardProps) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.IsSponsor { + templ_7745c5c3_Err = ThothTokenCard().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -122,33 +132,33 @@ func Navbar(login, avatarURL string) templ.Component { templ_7745c5c3_Var3 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "Logout") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -177,20 +187,20 @@ func DiscordCard(inviteURL string) templ.Component { templ_7745c5c3_Var6 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Discord Community

Connect with other sponsors and get early access to updates.

Discord Community

Connect with other sponsors and get early access to updates.

Join Discord
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" target=\"_blank\" class=\"btn btn-secondary p-2\">Join Discord") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -219,63 +229,63 @@ func SponsorshipCard(isSponsor bool, amount int, tier string, provider string) t templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Your Sponsorship

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Your Sponsorship

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if isSponsor { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
$") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
$") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatDollars(amount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 92, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "/month

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "/month

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tier) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Thank you for your support!

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

Thank you for your support!

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

You're not currently an active sponsor.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

You're not currently an active sponsor.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if provider == "patreon" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Become a Patron") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Become a Patron") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Become a Sponsor") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Become a Sponsor") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -304,7 +314,7 @@ func TeamInviteCard() templ.Component { templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Team Invitation

Invite team members to TecharoHQ as part of your sponsorship.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

Team Invitation

Invite team members to TecharoHQ as part of your sponsorship.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -333,7 +343,36 @@ func LogoSubmitCard() templ.Component { templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

Logo Submission

Submit your company logo for the Anubis README.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

Logo Submission

Submit your company logo for the Anubis README.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func ThothTokenCard() 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 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + 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_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

Thoth API Token

Generate an API token for Thoth services.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/cmd/sponsor-panel/templates/formsuccess.templ b/cmd/sponsor-panel/templates/formsuccess.templ index 8e9ad2f24..4bd462869 100644 --- a/cmd/sponsor-panel/templates/formsuccess.templ +++ b/cmd/sponsor-panel/templates/formsuccess.templ @@ -41,3 +41,42 @@ templ LogoSuccess(company, issueURL string, issueNumber int) { } + +// ThothTokenSuccess renders the success response for Thoth token generation. +templ ThothTokenSuccess(token string) { +
+
+ + + +
+

Token generated!

+

+ Set these environment variables in your Anubis/Botstopper deployment. +

+
+
{ "THOTH_URL=passthrough:///thoth.techaro.lol:443\nTHOTH_TOKEN=" + token }
+ +
+
+
+
+} diff --git a/cmd/sponsor-panel/templates/formsuccess_templ.go b/cmd/sponsor-panel/templates/formsuccess_templ.go index 99b4b072b..f9ff3af7c 100644 --- a/cmd/sponsor-panel/templates/formsuccess_templ.go +++ b/cmd/sponsor-panel/templates/formsuccess_templ.go @@ -133,4 +133,47 @@ func LogoSuccess(company, issueURL string, issueNumber int) templ.Component { }) } +// ThothTokenSuccess renders the success response for Thoth token generation. +func ThothTokenSuccess(token 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 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + 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_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Token generated!

Set these environment variables in your Anubis/Botstopper deployment.

")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var9 string
+		templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("THOTH_URL=passthrough:///thoth.techaro.lol:443\nTHOTH_TOKEN=" + token)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 58, Col: 206}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/docs/superpowers/plans/2026-04-01-thoth-token-issuance-design.md b/docs/superpowers/plans/2026-04-01-thoth-token-issuance-design.md new file mode 100644 index 000000000..383be45e3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-thoth-token-issuance-design.md @@ -0,0 +1,142 @@ +# Thoth Token Issuance Card + +## Context + +Sponsors need Thoth API tokens to access Thoth services. Currently there is no +self-service way to get these tokens -- they must be manually provisioned. This +feature adds a card to the sponsor panel dashboard that lets any $1+/month +sponsor generate a Thoth token with one click. + +The Thoth gRPC client is already wired into the sponsor panel (`server.thothClient`) +with `AdminUsers` and `AuthJWT` service clients. The missing pieces are: a +`ThothUserID` field on the user model, a handler to orchestrate user creation +and JWT minting, a template card, and a success response template. + +## Approach: Lazy Thoth User Creation + +Create the Thoth user on-demand when the sponsor first generates a token. Store +the Thoth user ID on the `PanelUser` model. Subsequent requests skip creation +and go straight to `MakeJWT`. + +## Changes + +### 1. Data Model (`models.go`) + +Add field to `PanelUser`: + +```go +ThothUserID *string `json:"thoth_user_id" gorm:"column:thoth_user_id"` +``` + +Nullable -- existing users get NULL. GORM AutoMigrate adds the column on next +startup. + +### 2. Handler (`handlers.go`) + +New function: `func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request)` + +Flow: + +1. Require POST method +2. Get session user via `s.getSessionUser(r)` +3. Check `user.IsSponsorAtTier(100)` (any $1+ sponsor) +4. If `user.ThothUserID == nil`: + - Call `s.thothClient.AdminUsers.Create(ctx, &adminv1.UsersServiceCreateRequest{...})` + - `EmailAddress`: `user.Email` + - `Name`: `user.Login` + - `CustomerId`: `user.Provider + ":" + user.Login` + - Save `resp.User.Id` to `user.ThothUserID` in the database +5. Call `s.thothClient.AdminUsers.MakeJWT(ctx, &adminv1.UsersServiceMakeJWTRequest{...})` + - `UserId`: `*user.ThothUserID` + - `Comment`: `"sponsor-panel token for " + user.Login` +6. Render `templates.ThothTokenSuccess(resp.TokenInfo.Jwt)` on success +7. Use `renderError()` for all error paths (same pattern as other handlers) + +### 3. Templates (`templates/dashboard.templ`) + +New card component: + +``` +templ ThothTokenCard() +``` + +- Default card style (no color variant, like LogoSubmitCard) +- Key/token icon in header +- Title: "Thoth API Token" +- Description: "Generate an API token for Thoth services." +- Single button: "Generate Token" +- `hx-post="/thoth-token"` `hx-target="#thoth-result"` +- Result div: `
` + +New success component (in `formresult.templ` or a new file): + +``` +templ ThothTokenSuccess(token string) +``` + +- Renders inside an `alert-success` div +- Contains a `
` block with:
+  ```
+  THOTH_URL=passthrough:///thoth.techaro.lol:443
+  THOTH_TOKEN=
+  ```
+- Monospace styling so users can copy-paste
+
+### 4. Dashboard Layout (`templates/dashboard.templ`)
+
+Add `ThothTokenCard()` to the second row grid, shown when `props.IsSponsor` is
+true:
+
+```templ
+
+ if props.IsFiftyPlus { + @TeamInviteCard() + } + if props.IsSponsor { + @LogoSubmitCard() + } + if props.IsSponsor { + @ThothTokenCard() + } +
+``` + +If a $50+ sponsor sees all three cards (TeamInvite, LogoSubmit, ThothToken), +add a third row for ThothTokenCard to keep the 2-column grid clean. + +### 5. Routing (`main.go`) + +Add route: + +```go +mux.HandleFunc("/thoth-token", server.thothTokenHandler) +``` + +Add to the debug routes list. + +## Files Modified + +- `cmd/sponsor-panel/models.go` -- add ThothUserID field to PanelUser +- `cmd/sponsor-panel/handlers.go` -- add thothTokenHandler + renderThothSuccess +- `cmd/sponsor-panel/templates/dashboard.templ` -- add ThothTokenCard, wire into layout +- `cmd/sponsor-panel/templates/formresult.templ` -- add ThothTokenSuccess component +- `cmd/sponsor-panel/main.go` -- add /thoth-token route + +## Existing Code to Reuse + +- `server.getSessionUser(r)` -- session auth (`oauth.go:634`) +- `user.IsSponsorAtTier(100)` -- tier check (`models.go:38`) +- `renderError(w, msg, code)` -- error rendering (`handlers.go:302`) +- `templates.FormResult(msg, bool)` -- generic result component (`formresult.templ`) +- `server.thothClient.AdminUsers` -- gRPC client (`internal/thoth/thoth.go`) +- Generated proto types from `xeiaso.net/v4/gen/techaro/thoth/auth/admin/v1` + +## Verification + +1. `go build ./cmd/sponsor-panel` -- compiles without errors +2. `npm test` -- all tests pass +3. `npm run dev:sponsor-panel` -- start dev server +4. Log in as a sponsor, verify the Thoth card appears +5. Click "Generate Token", verify credentials are displayed +6. Click again, verify it works without creating a duplicate Thoth user +7. Check database: `ThothUserID` column populated after first generation diff --git a/go.mod b/go.mod index 2b7fc15e7..46b2cf0d5 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( github.com/google/subcommands v1.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 github.com/jackc/pgx/v5 v5.9.1 github.com/joho/godotenv v1.5.1 github.com/orandin/slog-gorm v1.4.0 diff --git a/go.sum b/go.sum index 0f07729ff..b51504c86 100644 --- a/go.sum +++ b/go.sum @@ -377,6 +377,10 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= diff --git a/web/htmx/htmx_templ.go b/web/htmx/htmx_templ.go index 1fb5e0b42..609450111 100644 --- a/web/htmx/htmx_templ.go +++ b/web/htmx/htmx_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.2.731 package htmx //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -21,9 +21,6 @@ import templruntime "github.com/a-h/templ/runtime" func Use(exts ...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 - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { defer func() { @@ -39,44 +36,42 @@ func Use(exts ...string) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, ext := range exts { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - return nil + return templ_7745c5c3_Err }) } - -var _ = templruntime.GeneratedTemplate