feat: device-code sign-in for the CLI, with a browser approval page at /cli that mints a scoped API key, self-revocation at DELETE /api-keys/self so a read-only credential can always end itself, and app_url plus websocket_url on /auth/config so a client can find the dashboard and the realtime gateway on a self-hosted layout

This commit is contained in:
Matthew Meszaros
2026-09-04 20:14:27 -07:00
parent 32d2bdfb81
commit 028689fd2e
20 changed files with 1358 additions and 0 deletions
+6
View File
@@ -40,6 +40,7 @@ import (
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cipher"
"github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -162,6 +163,7 @@ func main() {
var emailService email.EmailService
var poolLinkService poollink.Service
var cloudLinkService cloudlink.Service
var cliAuthService cliauth.Service
var campaignService campaign.CampaignService
var analyticsService analytics.AnalyticsService
var rateLimitService ratelimit.RateLimitService
@@ -1256,6 +1258,9 @@ func main() {
leadSyncServiceForHandler = leadsync.NewService(leadSyncRepository, integrationServiceForHandler, contactService)
apiKeyService = apikey.NewService(cache, apiKeyRepository)
// `warmbly auth login`: the browser approval mints an ordinary API key
// through the service above, so it has to be built after it.
cliAuthService = cliauth.NewService(repository.NewCLIAuthRepository(primaryDB.Pool), apiKeyService, organizationService, userService, organizationRepository)
crmService = crm.NewService(crmRepository)
teamRepository := repository.NewTeamRepository(primaryDB.Pool)
teamService = team.NewService(teamRepository)
@@ -1896,6 +1901,7 @@ func main() {
PoolLinkService: poolLinkService,
CloudLinkService: cloudLinkService,
CLIAuthService: cliAuthService,
TokenService: tokenService,
PasskeyService: passkeyService,
+31
View File
@@ -171,6 +171,37 @@ func (h *Handler) RevokeAPIKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// RevokeOwnAPIKey revokes the key the request was made with.
//
// Deliberately outside the API_KEYS scope gate: a credential must always be
// able to end itself. Requiring a privilege to sign out means a read-only key
// on a laptop someone is handing back stays live, which is the opposite of
// what a `warmbly auth logout` promises.
func (h *Handler) RevokeOwnAPIKey(c *gin.Context) {
keyID := middleware.GetAPIKeyID(c)
if keyID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "this endpoint revokes the API key it is called with, and this request did not use one"))
return
}
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
reason := c.Query("reason")
if reason == "" {
reason = "Revoked by the credential itself"
}
if xerr := h.APIKeyService.Revoke(c.Request.Context(), *orgID, *keyID, reason); xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionRevoke, models.AuditEntityAPIKey, keyID, nil, map[string]string{"self": "true"})
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
// ListAPIPermissions lists all available API permissions
// GET /api-keys/permissions
func (h *Handler) ListAPIPermissions(c *gin.Context) {
+14
View File
@@ -72,6 +72,18 @@ type DeploymentAuthConfig struct {
// DocsURL is where to send someone whose signup was refused by deployment
// policy rather than by anything they did wrong.
DocsURL string `json:"docs_url"`
// WebsocketURL is the realtime gateway. Served here because a developer
// client (the CLI's event stream, an SDK) has no other way to find the
// socket on a self-hosted instance. Empty when the instance runs no
// realtime service.
WebsocketURL string `json:"websocket_url,omitempty"`
// AppURL is the dashboard origin, the same one every emailed link is built
// from. A client that wants to send someone to a page (the CLI's `browse`,
// a chat integration) cannot derive it: on a self-hosted instance the host
// layout is whatever the operator chose.
AppURL string `json:"app_url,omitempty"`
}
// accountsDocsURL is the page every registration refusal points at.
@@ -104,5 +116,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
InvitesRequired: registration == config.RegistrationInviteOnly,
DocsURL: accountsDocsURL,
WebsocketURL: config.WebsocketURL(),
AppURL: config.AppBaseURL(),
})
}
+126
View File
@@ -0,0 +1,126 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// Device-code sign-in for the `warmbly` CLI. The public half (start, poll) is
// what the CLI calls; the session half is the browser approval screen.
func (h *Handler) cliAuthReady(c *gin.Context) bool {
if h.CLIAuthService == nil {
errx.JSON(c, errx.New(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
return false
}
return true
}
// CLIAuthStart opens a handshake for a CLI that holds no key yet.
func (h *Handler) CLIAuthStart(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req models.CLIAuthStartRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
res, xerr := h.CLIAuthService.StartCode(c.Request.Context(), req)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusCreated, res)
}
// CLIAuthPoll is polled by the CLI until a member approves the code. The key is
// handed out exactly once, on the poll that follows approval.
func (h *Handler) CLIAuthPoll(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
var req struct {
DeviceCode string `json:"device_code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeviceCode == "" {
errx.JSON(c, errx.New(errx.BadRequest, "device_code is required"))
return
}
res, xerr := h.CLIAuthService.PollCode(c.Request.Context(), req.DeviceCode)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, res)
}
// CLIAuthDescribeCode shows the approving member what they are authorizing.
func (h *Handler) CLIAuthDescribeCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
code, xerr := h.CLIAuthService.DescribeCode(c.Request.Context(), c.Param("code"))
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, code)
}
// CLIAuthApproveCode mints the key into the workspace named in the body, not
// the session's, because a member with several workspaces picks on the screen.
func (h *Handler) CLIAuthApproveCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
userID, err := middleware.GetUserUUID(c)
if err != nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
var req models.CLIAuthApproveRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
orgID, perr := uuid.Parse(req.OrganizationID)
if perr != nil {
if sessionOrg := middleware.GetOrganizationID(c); sessionOrg != nil {
orgID = *sessionOrg
} else {
errx.JSON(c, errx.ErrNoOrganization)
return
}
}
code, xerr := h.CLIAuthService.ApproveCode(c.Request.Context(), c.Param("code"), orgID, userID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
// Logged against the org that was picked on screen, which is not always the
// session's, so this cannot go through auditOrg.
h.AuditService.LogAction(c.Request.Context(), orgID, userID, models.AuditActionCreate, models.AuditEntityAPIKey, code.APIKeyID,
c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{"source": "cli", "client": code.ClientName, "hostname": code.Hostname})
c.JSON(http.StatusOK, code)
}
// CLIAuthDenyCode declines the request. Deliberately not audited: nothing was
// created, and a denial is not a change to the workspace.
func (h *Handler) CLIAuthDenyCode(c *gin.Context) {
if !h.cliAuthReady(c) {
return
}
if xerr := h.CLIAuthService.DenyCode(c.Request.Context(), c.Param("code")); xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+4
View File
@@ -14,6 +14,7 @@ import (
"github.com/warmbly/warmbly/internal/app/behavior"
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -311,6 +312,9 @@ type Handler struct {
PoolLinkService poollink.Service
CloudLinkService cloudlink.Service
// Device-code sign-in for the `warmbly` CLI. Nil-safe: routes answer 501.
CLIAuthService cliauth.Service
// Infrastructure liveness probes for the admin System Status page.
// Wired in cmd/backend/main.go where the concrete clients live.
SystemChecker *sysstatus.Checker
+28
View File
@@ -240,6 +240,17 @@ func Run(
poolLinkPublic.POST("/poll", h.PoolLinkPoll)
}
// `warmbly auth login`. Unauthenticated by nature (the CLI has no key
// yet), so it shares the per-IP budget with the other public handshakes.
// Registered above the /auth group because that group's own rate limiter
// is tuned for password work, and a poll every three seconds is not that.
cliAuthPublic := v1.Group("/auth/cli")
cliAuthPublic.Use(m.AuthIPRateLimitMiddleware())
{
cliAuthPublic.POST("/code", h.CLIAuthStart)
cliAuthPublic.POST("/poll", h.CLIAuthPoll)
}
auth := v1.Group("/auth")
// Every unauthenticated auth route shares one per-IP budget. Nothing
// throttled these before: RateLimitMiddleware is keyed on the user id and
@@ -749,6 +760,11 @@ func Run(
// API key management. JWT users need PermManageAPIKeys; API keys
// need the APIPermAPIKeys self-service bit. This lets an integration
// rotate its own keys without going through the dashboard.
// Self-revocation, outside the API_KEYS gate below on purpose: any
// valid key may end itself, which is what makes signing a machine
// out actually end its access.
protected.DELETE("/api-keys/self", m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite), h.RevokeOwnAPIKey)
apiKeys := protected.Group("/api-keys")
apiKeys.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys))
apiKeys.Use(m.RateLimitMiddleware(models.RateLimitWrite))
@@ -1218,6 +1234,18 @@ func Run(
poolLink.GET("/instances", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkListInstances)
poolLink.DELETE("/instances/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkRevokeInstance)
}
// Browser half of `warmbly auth login`: a member reviews the code
// and approves it into one of their workspaces. Session-only, like
// the pool link approval, because approving mints a credential and
// an API key must not be able to mint another CLI's key.
cliAuth := jwtOnly.Group("/auth/cli")
cliAuth.Use(m.RateLimitMiddleware(models.RateLimitWrite))
{
cliAuth.GET("/codes/:code", h.CLIAuthDescribeCode)
cliAuth.POST("/codes/:code/approve", h.CLIAuthApproveCode)
cliAuth.POST("/codes/:code/deny", h.CLIAuthDenyCode)
}
// The linked instance's own surface, authenticated by its token.
poolLinkInstance := base.Group("/pool-link/instance")
poolLinkInstance.Use(m.PoolLinkAuthMiddleware())
+286
View File
@@ -0,0 +1,286 @@
// Package cliauth is the device-code sign-in the `warmbly` CLI uses.
//
// The CLI has no credential of its own, so it opens a handshake, shows the
// user an eight character code, and polls. A signed-in member approves the
// code in the browser, and the approval mints an ordinary API key through the
// existing service: same hash, same scopes, same revocation, visible under
// Settings > API keys like every other key. Nothing here is a new credential
// type and nothing here is a new authentication path.
package cliauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/apikey"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/user"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
var (
ErrCodeNotFound = errx.NewWithIdentifier(errx.NotFound, "cli_auth_code_not_found", "That code is unknown or has expired. Run `warmbly auth login` again for a fresh one.")
ErrCodeNotPending = errx.NewWithIdentifier(errx.Conflict, "cli_auth_code_used", "That code has already been used.")
ErrBadRequest = errx.NewWithIdentifier(errx.BadRequest, "cli_auth_request", "device_code is required.")
ErrBadScopes = errx.NewWithIdentifier(errx.BadRequest, "cli_auth_scopes", "The requested scopes include bits this instance does not grant.")
ErrForbidden = errx.NewWithIdentifier(errx.Forbidden, "cli_auth_forbidden", "Managing API keys is required to authorize a CLI in this workspace.")
)
type Service interface {
// StartCode opens a handshake for a CLI that holds no key yet.
StartCode(ctx context.Context, req models.CLIAuthStartRequest) (*models.CLIAuthStartResponse, *errx.Error)
// PollCode is what the CLI calls until a member decides.
PollCode(ctx context.Context, deviceCode string) (*models.CLIAuthPollResponse, *errx.Error)
// DescribeCode is what the approving member sees before deciding.
DescribeCode(ctx context.Context, userCode string) (*models.CLIAuthCode, *errx.Error)
// ApproveCode mints the key into the named workspace.
ApproveCode(ctx context.Context, userCode string, orgID, userID uuid.UUID) (*models.CLIAuthCode, *errx.Error)
DenyCode(ctx context.Context, userCode string) *errx.Error
}
type service struct {
repo repository.CLIAuthRepository
keys apikey.APIKeyService
orgs organization.OrganizationService
users user.UserService
orgRep repository.OrganizationRepository
}
func NewService(
repo repository.CLIAuthRepository,
keys apikey.APIKeyService,
orgs organization.OrganizationService,
users user.UserService,
orgRep repository.OrganizationRepository,
) Service {
return &service{repo: repo, keys: keys, orgs: orgs, users: users, orgRep: orgRep}
}
// Unambiguous alphabet: no 0/O, 1/I/L. Same as the pool link handshake, because
// both codes get read off one screen and typed into another.
const userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
func randomUserCode() (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", err
}
out := make([]byte, 0, 9)
for i, v := range b {
if i == 4 {
out = append(out, '-')
}
out = append(out, userCodeAlphabet[int(v)%len(userCodeAlphabet)])
}
return string(out), nil
}
func randomDeviceCode() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func hashDeviceCode(t string) string {
sum := sha256.Sum256([]byte(t))
return hex.EncodeToString(sum[:])
}
// NormalizeUserCode accepts any case, with or without the dash, so a user who
// retypes the code by hand is not punished for the formatting.
func NormalizeUserCode(raw string) string {
raw = strings.ToUpper(strings.TrimSpace(raw))
raw = strings.ReplaceAll(raw, "-", "")
raw = strings.ReplaceAll(raw, " ", "")
if len(raw) != 8 {
return raw
}
return raw[:4] + "-" + raw[4:]
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n]
}
return s
}
func (s *service) StartCode(ctx context.Context, req models.CLIAuthStartRequest) (*models.CLIAuthStartResponse, *errx.Error) {
req.ClientName = clip(req.ClientName, 60)
if req.ClientName == "" {
req.ClientName = "Warmbly CLI"
}
req.Hostname = clip(req.Hostname, 80)
req.CLIVersion = clip(req.CLIVersion, 40)
// An unknown bit would grant a scope the approval screen never showed.
if req.Scopes&^models.AllAPIPermissionsMask != 0 {
return nil, ErrBadScopes
}
if req.Scopes == 0 {
req.Scopes = models.APIPermFullAccess
}
deviceCode, err := randomDeviceCode()
if err != nil {
return nil, errx.InternalError()
}
_ = s.repo.DeleteExpiredCodes(ctx)
var code *models.CLIAuthCode
for attempt := 0; attempt < 3; attempt++ {
userCode, uerr := randomUserCode()
if uerr != nil {
return nil, errx.InternalError()
}
created, cerr := s.repo.CreateCode(ctx, hashDeviceCode(deviceCode), userCode, req, time.Now().Add(config.CLIAuthCodeTTLMinutes*time.Minute))
if cerr == nil && created != nil {
code = created
break
}
// A user-code collision is the only expected failure; retry with a new one.
}
if code == nil {
return nil, errx.InternalError()
}
verify := config.AppBaseURL() + "/cli"
return &models.CLIAuthStartResponse{
DeviceCode: deviceCode,
UserCode: code.UserCode,
VerificationURL: verify,
VerificationURLComplete: verify + "?code=" + url.QueryEscape(code.UserCode),
ExpiresIn: config.CLIAuthCodeTTLMinutes * 60,
Interval: config.CLIAuthPollIntervalSeconds,
}, nil
}
func (s *service) PollCode(ctx context.Context, deviceCode string) (*models.CLIAuthPollResponse, *errx.Error) {
deviceCode = strings.TrimSpace(deviceCode)
if deviceCode == "" {
return nil, ErrBadRequest
}
code, secret, err := s.repo.ClaimCode(ctx, hashDeviceCode(deviceCode))
if err != nil {
return nil, errx.InternalError()
}
if code == nil {
// Expired and unknown are the same answer on purpose: a poller that
// can tell them apart can probe for live handshakes.
return nil, ErrCodeNotFound
}
res := &models.CLIAuthPollResponse{Status: code.Status}
if secret == "" {
return res, nil
}
res.Token = secret
res.Scopes = code.Scopes
res.ScopeNames = code.ScopeNames
res.OrganizationID = code.OrganizationID
// Identity is a convenience for `warmbly auth status`, not part of the
// grant, so a lookup failure must not lose the user their token.
if key, kerr := s.keys.ValidateKey(ctx, secret); kerr == nil && key != nil {
res.APIKeyID = &key.ID
res.UserID = &key.UserID
if u, uerr := s.users.GetUser(ctx, key.UserID); uerr == nil && u != nil {
res.UserEmail = u.Email
res.UserName = strings.TrimSpace(u.FirstName + " " + u.LastName)
}
}
if code.OrganizationID != nil && s.orgRep != nil {
if org, oerr := s.orgRep.GetByID(ctx, *code.OrganizationID); oerr == nil && org != nil {
res.OrganizationName = org.Name
}
}
return res, nil
}
func (s *service) DescribeCode(ctx context.Context, userCode string) (*models.CLIAuthCode, *errx.Error) {
code, err := s.repo.GetCodeByUserCode(ctx, NormalizeUserCode(userCode))
if err != nil {
return nil, errx.InternalError()
}
if code == nil {
return nil, ErrCodeNotFound
}
return code, nil
}
func (s *service) ApproveCode(ctx context.Context, userCode string, orgID, userID uuid.UUID) (*models.CLIAuthCode, *errx.Error) {
userCode = NormalizeUserCode(userCode)
code, xerr := s.DescribeCode(ctx, userCode)
if xerr != nil {
return nil, xerr
}
if code.Status != models.CLIAuthCodePending {
return nil, ErrCodeNotPending
}
allowed, xerr := s.orgs.HasPermission(ctx, orgID, userID, models.PermManageAPIKeys)
if xerr != nil {
return nil, xerr
}
if !allowed {
return nil, ErrForbidden
}
// The key is named for the machine that asked, so Settings > API keys shows
// which laptop a key belongs to and revoking the right one is possible.
name := code.ClientName
if code.Hostname != "" {
name += " on " + code.Hostname
}
desc := fmt.Sprintf("Created by `warmbly auth login` for code %s", code.UserCode)
created, xerr := s.keys.Create(ctx, orgID, userID, &models.CreateAPIKey{
Name: clip(name, 255),
Description: &desc,
Permissions: code.Scopes,
})
if xerr != nil {
return nil, xerr
}
ok, err := s.repo.ApproveCode(ctx, userCode, orgID, userID, created.ID, created.Secret)
if err != nil {
return nil, errx.InternalError()
}
if !ok {
// Someone approved or denied between the read and the write. The key
// would otherwise be an orphan nobody asked for.
_ = s.keys.Revoke(ctx, orgID, created.ID, "cli authorization was resolved elsewhere")
return nil, ErrCodeNotPending
}
code.Status = models.CLIAuthCodeApproved
code.OrganizationID = &orgID
code.APIKeyID = &created.ID
return code, nil
}
func (s *service) DenyCode(ctx context.Context, userCode string) *errx.Error {
ok, err := s.repo.DenyCode(ctx, NormalizeUserCode(userCode))
if err != nil {
return errx.InternalError()
}
if !ok {
return ErrCodeNotFound
}
return nil
}
+1
View File
@@ -786,6 +786,7 @@ var ExcludedTables = map[string]string{
"dedicated_worker_assignments": "Worker topology, which is a property of the instance rather than the workspace.",
"warmup_pools": "Instance-global pool definitions shared by every workspace on the instance.",
"pool_link_codes": "In-flight link handshakes between a self-hosted instance and this cloud, valid for minutes.",
"cli_auth_codes": "In-flight `warmbly auth login` handshakes, valid for minutes. The API key an approval mints does travel, with the api_keys rows.",
"pool_link_instances": "Self-hosted instances linked to this workspace's pool allowance. The token hash only authenticates against this instance, and the enrolled mailboxes are mirrors of mailboxes that live elsewhere.",
"pool_link_mailboxes": "Which mailbox rows are warmup-only mirrors for a linked instance. They follow pool_link_instances, which does not travel.",
"cloud_link": "This instance's own link to Warmbly Cloud: an instance property, not workspace data, and its token would be wrong on any other instance.",
+5
View File
@@ -282,6 +282,11 @@ const (
WarmupPoolFallbackMinAgeDays = 3 // other-tier mailboxes must be this old before they fill in
DailyThrottleNewOrgs = 3 // new workspaces per owner per day
// CLI sign-in handshake (`warmbly auth login`). Shorter-lived than the pool
// link handshake because a person is watching the terminal while it runs.
CLIAuthCodeTTLMinutes = 10
CLIAuthPollIntervalSeconds = 3
// DailyThrottleNewScheduledSends caps how many NEW scheduled-send
// schedules a single user can create in a rolling 24h window. The
// real defense against burst abuse — someone writing a loop that
+17
View File
@@ -23,6 +23,23 @@ func AppBaseURL() string {
return "https://app.warmbly.com"
}
// WebsocketURL is the realtime gateway clients connect to. It is deployment
// configuration rather than a secret, which is why GET /v1/auth/config serves
// it: a CLI or a developer client cannot otherwise find the socket on a
// self-hosted instance, where the host layout is whatever the operator chose.
func WebsocketURL() string {
v := strings.TrimRight(strings.TrimSpace(os.Getenv("WEBSOCKET_URL")), "/")
if v == "" {
return ""
}
// The variable is written both ways in the wild: with the Phoenix path and
// without. Clients want the full endpoint.
if !strings.Contains(v, "/socket") {
v += "/socket/websocket"
}
return v
}
func GetPasswordResetURL(sessionToken string) string {
return AppBaseURL() + "/auth/reset-password/confirm?session=" + url.QueryEscape(sessionToken)
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS cli_auth_codes;
@@ -0,0 +1,28 @@
-- CLI sign-in: the `warmbly` CLI has no credential of its own, so it opens a
-- device-code handshake, a signed-in member approves it in the browser, and the
-- approval mints an ordinary API key. The key is the credential; this table
-- only carries the handshake and is empty within minutes.
--
-- Same shape as pool_link_codes, one row per `warmbly auth login`.
CREATE TABLE IF NOT EXISTS cli_auth_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_code_hash text NOT NULL UNIQUE,
user_code text NOT NULL UNIQUE,
-- What the CLI asked for, shown on the approval screen.
client_name text NOT NULL DEFAULT '',
hostname text NOT NULL DEFAULT '',
cli_version text NOT NULL DEFAULT '',
scopes bigint NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'claimed', 'denied')),
organization_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
approved_by uuid REFERENCES users (id) ON DELETE SET NULL,
api_key_id uuid REFERENCES api_keys (id) ON DELETE SET NULL,
-- The minted secret, held only between approval and the CLI's next poll.
api_key_secret text,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_cli_auth_codes_expires ON cli_auth_codes (expires_at);
+91
View File
@@ -0,0 +1,91 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CLI sign-in: `warmbly auth login` opens a device-code handshake, a member
// approves it in the browser, and the approval mints an ordinary API key.
// CLIAuthCodeStatus is the lifecycle of one handshake.
type CLIAuthCodeStatus string
const (
CLIAuthCodePending CLIAuthCodeStatus = "pending"
CLIAuthCodeApproved CLIAuthCodeStatus = "approved"
// Claimed: the CLI has fetched its key, the code is spent.
CLIAuthCodeClaimed CLIAuthCodeStatus = "claimed"
CLIAuthCodeDenied CLIAuthCodeStatus = "denied"
)
// CLIAuthCode is what the approving member is shown before deciding.
type CLIAuthCode struct {
ID uuid.UUID `json:"id"`
UserCode string `json:"user_code"`
ClientName string `json:"client_name"`
Hostname string `json:"hostname"`
CLIVersion string `json:"cli_version"`
Scopes uint64 `json:"scopes"`
ScopeNames []string `json:"scope_names"`
Status CLIAuthCodeStatus `json:"status"`
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
// APIKeyID is set only on the approval response: the key that was minted.
APIKeyID *uuid.UUID `json:"api_key_id,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// CLIAuthStartRequest is what the CLI sends to open a handshake. Every field is
// display-only except Scopes, which bounds the key the approval mints.
type CLIAuthStartRequest struct {
ClientName string `json:"client_name"`
Hostname string `json:"hostname"`
CLIVersion string `json:"cli_version"`
Scopes uint64 `json:"scopes"`
}
// CLIAuthStartResponse is RFC 8628 shaped, so a generic device-flow client works.
type CLIAuthStartResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_uri"`
// VerificationURLComplete carries the code, so the browser needs no typing.
VerificationURLComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// CLIAuthPollResponse answers one poll. Status is the only field always set;
// the key fields arrive exactly once, on the poll that claims an approved code.
type CLIAuthPollResponse struct {
Status CLIAuthCodeStatus `json:"status"`
Token string `json:"token,omitempty"`
APIKeyID *uuid.UUID `json:"api_key_id,omitempty"`
Scopes uint64 `json:"scopes,omitempty"`
ScopeNames []string `json:"scope_names,omitempty"`
UserID *uuid.UUID `json:"user_id,omitempty"`
UserEmail string `json:"user_email,omitempty"`
UserName string `json:"user_name,omitempty"`
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OrganizationName string `json:"organization_name,omitempty"`
}
// CLIAuthApproveRequest names the workspace the key is minted in.
type CLIAuthApproveRequest struct {
OrganizationID string `json:"organization_id"`
}
// APIScopeNames turns a permission bitmask into the scope names the CLI and the
// approval screen show, in the canonical order of AllAPIPermissions.
func APIScopeNames(mask uint64) []string {
names := make([]string, 0, len(AllAPIPermissions))
for _, p := range AllAPIPermissions {
if mask&p.Value == p.Value {
names = append(names, p.Name)
}
}
return names
}
+145
View File
@@ -0,0 +1,145 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
)
// CLIAuthRepository stores the `warmbly auth login` handshake. Rows live for
// minutes: the credential the flow produces is an ordinary API key.
type CLIAuthRepository interface {
CreateCode(ctx context.Context, deviceCodeHash, userCode string, req models.CLIAuthStartRequest, expiresAt time.Time) (*models.CLIAuthCode, error)
GetCodeByUserCode(ctx context.Context, userCode string) (*models.CLIAuthCode, error)
// ApproveCode stores the minted secret for the next poll; false when the
// code is no longer pending, which is what makes approval single-use.
ApproveCode(ctx context.Context, userCode string, orgID, approvedBy, apiKeyID uuid.UUID, secret string) (bool, error)
DenyCode(ctx context.Context, userCode string) (bool, error)
// ClaimCode hands the secret out exactly once, clearing it in the same statement.
ClaimCode(ctx context.Context, deviceCodeHash string) (*models.CLIAuthCode, string, error)
DeleteExpiredCodes(ctx context.Context) error
}
type cliAuthRepository struct {
db *pgxpool.Pool
}
func NewCLIAuthRepository(db *pgxpool.Pool) CLIAuthRepository {
return &cliAuthRepository{db: db}
}
const cliAuthCodeColumns = `id, user_code, client_name, hostname, cli_version, scopes, status, organization_id, expires_at, created_at`
func scanCLIAuthCode(row pgx.Row) (*models.CLIAuthCode, error) {
var c models.CLIAuthCode
var scopes int64
if err := row.Scan(&c.ID, &c.UserCode, &c.ClientName, &c.Hostname, &c.CLIVersion, &scopes, &c.Status, &c.OrganizationID, &c.ExpiresAt, &c.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
c.Scopes = uint64(scopes)
c.ScopeNames = models.APIScopeNames(c.Scopes)
return &c, nil
}
func (r *cliAuthRepository) CreateCode(ctx context.Context, deviceCodeHash, userCode string, req models.CLIAuthStartRequest, expiresAt time.Time) (*models.CLIAuthCode, error) {
query := `
INSERT INTO cli_auth_codes (device_code_hash, user_code, client_name, hostname, cli_version, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING ` + cliAuthCodeColumns
c, err := scanCLIAuthCode(r.db.QueryRow(ctx, query, deviceCodeHash, userCode, req.ClientName, req.Hostname, req.CLIVersion, int64(req.Scopes), expiresAt))
if err != nil {
db.CaptureError(err, query, nil, "queryrow")
return nil, err
}
return c, nil
}
func (r *cliAuthRepository) GetCodeByUserCode(ctx context.Context, userCode string) (*models.CLIAuthCode, error) {
query := `SELECT ` + cliAuthCodeColumns + ` FROM cli_auth_codes WHERE user_code = $1 AND expires_at > NOW()`
c, err := scanCLIAuthCode(r.db.QueryRow(ctx, query, userCode))
if err != nil {
db.CaptureError(err, query, []any{userCode}, "queryrow")
return nil, err
}
return c, nil
}
func (r *cliAuthRepository) ApproveCode(ctx context.Context, userCode string, orgID, approvedBy, apiKeyID uuid.UUID, secret string) (bool, error) {
query := `
UPDATE cli_auth_codes
SET status = 'approved', organization_id = $2, approved_by = $3, api_key_id = $4, api_key_secret = $5
WHERE user_code = $1 AND status = 'pending' AND expires_at > NOW()
`
tag, err := r.db.Exec(ctx, query, userCode, orgID, approvedBy, apiKeyID, secret)
if err != nil {
db.CaptureError(err, query, nil, "exec")
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (r *cliAuthRepository) DenyCode(ctx context.Context, userCode string) (bool, error) {
query := `UPDATE cli_auth_codes SET status = 'denied' WHERE user_code = $1 AND status = 'pending'`
tag, err := r.db.Exec(ctx, query, userCode)
if err != nil {
db.CaptureError(err, query, []any{userCode}, "exec")
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (r *cliAuthRepository) ClaimCode(ctx context.Context, deviceCodeHash string) (*models.CLIAuthCode, string, error) {
// The secret comes from the locked pre-update row; RETURNING would only
// see the cleared value.
query := `
WITH picked AS (
SELECT id, api_key_secret
FROM cli_auth_codes
WHERE device_code_hash = $1 AND status = 'approved' AND expires_at > NOW()
FOR UPDATE
), claimed AS (
UPDATE cli_auth_codes p
SET status = 'claimed', api_key_secret = NULL
FROM picked
WHERE p.id = picked.id
RETURNING p.id, p.user_code, p.client_name, p.hostname, p.cli_version, p.scopes, p.status, p.organization_id, p.expires_at, p.created_at, picked.api_key_secret AS secret
)
SELECT id, user_code, client_name, hostname, cli_version, scopes, status, organization_id, expires_at, created_at, COALESCE(secret, '') FROM claimed
UNION ALL
SELECT ` + cliAuthCodeColumns + `, '' FROM cli_auth_codes
WHERE device_code_hash = $1 AND expires_at > NOW() AND NOT EXISTS (SELECT 1 FROM claimed)
LIMIT 1
`
var c models.CLIAuthCode
var scopes int64
var secret string
err := r.db.QueryRow(ctx, query, deviceCodeHash).Scan(&c.ID, &c.UserCode, &c.ClientName, &c.Hostname, &c.CLIVersion, &scopes, &c.Status, &c.OrganizationID, &c.ExpiresAt, &c.CreatedAt, &secret)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
db.CaptureError(err, query, nil, "queryrow")
return nil, "", err
}
c.Scopes = uint64(scopes)
c.ScopeNames = models.APIScopeNames(c.Scopes)
// The claimed row reports its new status; the caller wants "approved".
if secret != "" {
c.Status = models.CLIAuthCodeApproved
}
return &c, secret, nil
}
func (r *cliAuthRepository) DeleteExpiredCodes(ctx context.Context) error {
_, err := r.db.Exec(ctx, `DELETE FROM cli_auth_codes WHERE expires_at < NOW() - INTERVAL '1 day'`)
return err
}
+500
View File
@@ -0,0 +1,500 @@
// app.warmbly.com/cli?code=XXXX-XXXX — a signed-in member authorizes the
// `warmbly` CLI running on one of their machines. Approving mints an ordinary
// API key in the workspace they pick, which the CLI collects on its next poll.
// Standalone on the auth screen's sky: enter code, review, done.
import React from "react";
import { Link, Navigate, useSearchParams } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import toast from "react-hot-toast";
import {
ArrowLeftIcon,
ArrowRightIcon,
BuildingIcon,
CheckIcon,
ExternalLinkIcon,
KeyRoundIcon,
Loader2Icon,
LockIcon,
MonitorIcon,
TerminalIcon,
XIcon,
} from "lucide-react";
import { Logo } from "@/components/svg";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { WEBSITE_URL } from "@/lib/information";
import getToken from "@/lib/helper/getToken";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import type Organization from "@/lib/api/models/app/organizations/Organization";
import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations";
import type { CLIAuthCode } from "@/lib/api/models/app/cliauth/CLIAuth";
import { useApproveCLIAuthCode, useCLIAuthCode, useDenyCLIAuthCode } from "@/lib/api/hooks/app/cliauth/useCLIAuth";
const CODE_LENGTH = 8;
function clean(raw: string): string {
return raw.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, CODE_LENGTH);
}
function dashed(code: string): string {
return code.length > 4 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
}
const slide = {
enter: (dir: number) => ({ opacity: 0, x: dir > 0 ? 28 : -28 }),
center: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: dir > 0 ? -28 : 28 }),
};
const slideTransition = { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const };
export default function CLIAuthPage() {
if (!getToken()) {
const next = encodeURIComponent(window.location.pathname + window.location.search);
return <Navigate to={`/auth/login?next=${next}`} replace />;
}
return <CLIAuthInner />;
}
function CLIAuthInner() {
const [params] = useSearchParams();
const [code, setCode] = React.useState(() => clean(params.get("code") ?? ""));
const complete = code.length === CODE_LENGTH;
const lookup = useCLIAuthCode(dashed(code));
const orgs = useOrganizations();
const approve = useApproveCLIAuthCode();
const deny = useDenyCLIAuthCode();
const [orgId, setOrgId] = React.useState("");
const [outcome, setOutcome] = React.useState<"approved" | "denied" | null>(null);
const [dir, setDir] = React.useState(1);
React.useEffect(() => {
if (!orgId && orgs.data && orgs.data.length > 0) setOrgId(orgs.data[0].id);
}, [orgs.data, orgId]);
const info = complete ? lookup.data : undefined;
const step: "code" | "review" | "done" = outcome ? "done" : info ? "review" : "code";
const reset = () => {
setDir(-1);
setCode("");
setOutcome(null);
};
const doApprove = async () => {
try {
setDir(1);
await approve.mutateAsync({ code: dashed(code), organizationId: orgId });
setOutcome("approved");
} catch (e) {
toast.error(buildError(e as AppError));
}
};
const doDeny = async () => {
try {
setDir(1);
await deny.mutateAsync(dashed(code));
setOutcome("denied");
} catch (e) {
toast.error(buildError(e as AppError));
}
};
return (
<div className="relative min-h-dvh w-full overflow-hidden flex flex-col items-center justify-center px-4 py-8 sm:py-10">
<div className="absolute inset-0" aria-hidden="true">
<div className="sky-base" />
<div className="sky-breathe" />
<div className="sun-glow" />
<img src="/backdrops/cloud-3.webp" alt="" decoding="async" className="cloud-drift cloud-1 absolute select-none" style={{ top: "6%", left: "-10%", width: 360, opacity: 0.55, height: "auto" }} />
<img src="/backdrops/cloud-4.webp" alt="" decoding="async" className="cloud-drift cloud-2 absolute select-none" style={{ bottom: "8%", right: "-8%", width: 320, opacity: 0.5, height: "auto" }} />
<img src="/backdrops/cloud-1.webp" alt="" decoding="async" className="cloud-drift cloud-1 absolute select-none" style={{ top: "44%", right: "14%", width: 220, opacity: 0.35, height: "auto" }} />
</div>
<div className="relative z-10 w-full max-w-[560px]">
<a href={WEBSITE_URL} className="mb-5 flex w-fit items-center gap-2.5 mx-auto">
<Logo className="w-7 text-white" />
<span className="font-extrabold text-[18px] tracking-tight text-white">Warmbly</span>
</a>
<motion.div
initial={{ y: 14, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="animate-card-float rounded-3xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04),0_30px_70px_-32px_rgba(15,23,42,0.32)] overflow-hidden"
>
<Steps current={step} />
<div className="px-6 pb-7 pt-2 sm:px-10 sm:pb-9 overflow-hidden">
<AnimatePresence mode="wait" initial={false} custom={dir}>
{step === "code" && (
<motion.div key="code" custom={dir} variants={slide} initial="enter" animate="center" exit="exit" transition={slideTransition}>
<CodeStep code={code} setCode={setCode} loading={complete && lookup.isLoading} error={complete && lookup.isError ? (lookup.error as unknown as AppError) : null} onRetry={reset} />
</motion.div>
)}
{step === "review" && info && (
<motion.div key="review" custom={dir} variants={slide} initial="enter" animate="center" exit="exit" transition={slideTransition}>
<ReviewStep
info={info}
orgs={orgs.data ?? []}
orgsLoading={orgs.isLoading}
orgId={orgId}
setOrgId={setOrgId}
busy={approve.isPending || deny.isPending}
approving={approve.isPending}
onApprove={doApprove}
onDeny={doDeny}
onBack={reset}
/>
</motion.div>
)}
{step === "done" && info && (
<motion.div key="done" custom={dir} variants={slide} initial="enter" animate="center" exit="exit" transition={slideTransition}>
<DoneStep approved={outcome === "approved"} info={info} orgName={orgs.data?.find((o) => o.id === orgId)?.name} onAnother={reset} />
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
<div className="mt-5 flex items-center justify-center gap-3 text-[12px] text-white/70">
<Link to="/app/emails" className="hover:text-white transition-colors">Back to dashboard</Link>
<span className="text-white/40">·</span>
<a href="https://docs.warmbly.com/api/cli/" target="_blank" rel="noreferrer" className="hover:text-white transition-colors">About the CLI</a>
</div>
</div>
</div>
);
}
const STEPS: { key: "code" | "review" | "done"; label: string }[] = [
{ key: "code", label: "Code" },
{ key: "review", label: "Review" },
{ key: "done", label: "Signed in" },
];
function Steps({ current }: { current: "code" | "review" | "done" }) {
const idx = STEPS.findIndex((s) => s.key === current);
return (
<div className="px-6 sm:px-10 pt-7 pb-5 flex items-center gap-2">
{STEPS.map((s, i) => {
const state = i < idx ? "done" : i === idx ? "current" : "todo";
return (
<React.Fragment key={s.key}>
<div className="flex items-center gap-2">
<motion.span
animate={{
backgroundColor: state === "todo" ? "#f1f5f9" : "#0284c7",
color: state === "todo" ? "#94a3b8" : "#ffffff",
}}
className="size-6 rounded-full inline-flex items-center justify-center text-[11px] font-semibold"
>
{state === "done" ? <CheckIcon className="w-3 h-3" /> : i + 1}
</motion.span>
<span className={`text-[12px] font-medium ${state === "todo" ? "text-slate-400" : "text-slate-900"}`}>{s.label}</span>
</div>
{i < STEPS.length - 1 && (
<span className="relative flex-1 h-px bg-slate-200 overflow-hidden rounded-full">
<motion.span animate={{ width: i < idx ? "100%" : "0%" }} transition={{ duration: 0.4 }} className="absolute inset-y-0 left-0 bg-sky-600" />
</span>
)}
</React.Fragment>
);
})}
</div>
);
}
function CodeStep({ code, setCode, loading, error, onRetry }: { code: string; setCode: (c: string) => void; loading: boolean; error: AppError | null; onRetry: () => void }) {
return (
<div>
<div className="text-center">
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-sky-50 text-sky-700 text-[11px] font-medium">
<TerminalIcon className="w-3 h-3" /> Warmbly CLI
</span>
<h1 className="mt-4 text-[24px] sm:text-[28px] font-semibold tracking-[-0.03em] leading-[1.1] text-slate-900">Sign in to the CLI</h1>
<p className="mt-2.5 text-[13.5px] text-slate-500 leading-relaxed max-w-md mx-auto">
Enter the eight character code your terminal is showing. Approving it creates an API key for that machine, which you can revoke here at any time.
</p>
</div>
<div className="mt-8 flex justify-center">
<InputOTP maxLength={CODE_LENGTH} value={code} onChange={(v) => setCode(clean(v))} pattern={REGEXP_ONLY_DIGITS_AND_CHARS} pasteTransformer={clean} autoFocus containerClassName="gap-1.5 sm:gap-2" disabled={loading}>
<InputOTPGroup className="gap-1.5 sm:gap-2">
{[0, 1, 2, 3].map((i) => (
<Slot key={i} index={i} />
))}
</InputOTPGroup>
<span className="w-3 h-px bg-slate-300 mx-0.5 sm:mx-1" aria-hidden="true" />
<InputOTPGroup className="gap-1.5 sm:gap-2">
{[4, 5, 6, 7].map((i) => (
<Slot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
</div>
<div className="mt-5 min-h-[44px] flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
{loading && (
<motion.p key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="inline-flex items-center gap-2 text-[12.5px] text-slate-500">
<Loader2Icon className="w-3.5 h-3.5 animate-spin" /> Looking up your terminal
</motion.p>
)}
{error && !loading && (
<motion.div key="error" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="w-full rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-center">
<p className="text-[13px] font-medium text-rose-700">{error.message || "That code is unknown or has expired."}</p>
<p className="mt-0.5 text-[12px] text-rose-600/80">
Run <span className="font-mono">warmbly auth login</span> again for a fresh one, then{" "}
<button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-rose-800">
enter it here
</button>
.
</p>
</motion.div>
)}
{!loading && !error && (
<motion.p key="hint" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-[12px] text-slate-400 text-center">
You can paste the whole code. Codes expire ten minutes after the terminal printed them.
</motion.p>
)}
</AnimatePresence>
</div>
</div>
);
}
function Slot({ index }: { index: number }) {
return (
<InputOTPSlot
index={index}
className="!w-10 !h-12 sm:!w-12 sm:!h-14 !rounded-lg !border !border-slate-200 !shadow-none first:!rounded-lg last:!rounded-lg text-[20px] font-semibold font-mono text-slate-900 data-[active=true]:!border-sky-400 data-[active=true]:!ring-sky-400/15"
/>
);
}
// The scope names the API returns are SCREAMING_SNAKE; the reviewer reads prose.
function scopeLabel(name: string): string {
const words = name.toLowerCase().replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
function ReviewStep({
info,
orgs,
orgsLoading,
orgId,
setOrgId,
busy,
approving,
onApprove,
onDeny,
onBack,
}: {
info: CLIAuthCode;
orgs: Organization[];
orgsLoading: boolean;
orgId: string;
setOrgId: (id: string) => void;
busy: boolean;
approving: boolean;
onApprove: () => void;
onDeny: () => void;
onBack: () => void;
}) {
const pending = info.status === "pending";
const sends = info.scope_names.includes("SEND_CAMPAIGNS") || info.scope_names.includes("WRITE_UNIBOX");
return (
<div>
<button type="button" onClick={onBack} className="inline-flex items-center gap-1 text-[12px] text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeftIcon className="w-3.5 h-3.5" /> Different code
</button>
<h1 className="mt-3 text-[22px] sm:text-[26px] font-semibold tracking-[-0.03em] leading-[1.1] text-slate-900">
{pending ? "Authorize this terminal" : "This code was already used"}
</h1>
<div className="mt-5 flex items-center gap-4 rounded-xl border border-slate-200 bg-gradient-to-b from-sky-50/60 to-white px-4 py-4">
<span className="size-11 rounded-lg bg-slate-900 text-white inline-flex items-center justify-center shrink-0">
<TerminalIcon className="w-5 h-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold text-slate-900 truncate">{info.client_name || "Warmbly CLI"}</p>
<p className="text-[12px] text-slate-500 truncate inline-flex items-center gap-1.5">
<MonitorIcon className="w-3 h-3 shrink-0" />
{info.hostname || "Machine name not shared"}
{info.cli_version && <span className="text-slate-400">· v{info.cli_version}</span>}
</p>
</div>
<span className="hidden sm:inline-flex font-mono text-[13px] tracking-[0.18em] text-slate-400">{info.user_code}</span>
</div>
{!pending ? (
<div className="mt-5">
<p className="text-[13px] text-slate-500 leading-relaxed">
{info.status === "denied"
? "The request was declined. Run `warmbly auth login` again if you changed your mind."
: "It is signed in already. If the terminal is still waiting, run `warmbly auth login` again for a fresh code."}
</p>
<div className="mt-5 flex items-center gap-2">
<Link to="/app/api-keys" className="h-10 px-4 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13px] font-medium inline-flex items-center gap-1.5 transition-colors">
API keys <ArrowRightIcon className="w-3.5 h-3.5" />
</Link>
</div>
</div>
) : (
<>
<div className="mt-6">
<p className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Sign in to workspace</p>
<div className="mt-2 space-y-1.5">
{orgsLoading && (
<div className="h-12 rounded-lg border border-slate-200 flex items-center justify-center text-slate-400">
<Loader2Icon className="w-4 h-4 animate-spin" />
</div>
)}
{orgs.map((o) => {
const on = o.id === orgId;
return (
<button
key={o.id}
type="button"
onClick={() => setOrgId(o.id)}
className={`w-full flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
on ? "border-sky-400 bg-sky-50/60 ring-2 ring-sky-100" : "border-slate-200 hover:border-slate-300"
}`}
>
<span className={`size-8 rounded-md inline-flex items-center justify-center shrink-0 overflow-hidden ${on ? "bg-sky-600 text-white" : "bg-slate-100 text-slate-600"}`}>
{o.avatar ? <img src={o.avatar} alt="" className="size-full object-cover" /> : <BuildingIcon className="w-4 h-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium text-slate-900 truncate">{o.name}</span>
<span className="block text-[11.5px] text-slate-500 capitalize">{o.role}</span>
</span>
<span className={`size-4 rounded-full border inline-flex items-center justify-center ${on ? "border-sky-600 bg-sky-600 text-white" : "border-slate-300"}`}>
{on && <CheckIcon className="w-2.5 h-2.5" />}
</span>
</button>
);
})}
{!orgsLoading && orgs.length === 0 && <p className="text-[12.5px] text-slate-500">You are not a member of any workspace yet.</p>}
</div>
</div>
<div className="mt-5">
<p className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
This terminal will be able to
</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{info.scope_names.map((s) => (
<li key={s} className="h-6 px-2 rounded-md bg-slate-100 text-slate-700 text-[11.5px] inline-flex items-center">
{scopeLabel(s)}
</li>
))}
{info.scope_names.length === 0 && <li className="text-[12.5px] text-slate-500">Nothing. The CLI asked for no scopes.</li>}
</ul>
</div>
{sends && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12.5px] text-amber-800 leading-relaxed">
These scopes include sending. A CLI signed in with them can start campaigns and send replies, which puts real mail on the wire.
</p>
)}
<ul className="mt-5 grid sm:grid-cols-2 gap-2.5">
<Perm icon={KeyRoundIcon} title="What this creates" body="One API key named for this machine, listed under API keys, revocable there or with `warmbly auth logout`." />
<Perm icon={LockIcon} title="What it is not" body="Not your password and not a session. It only carries the scopes above, in the workspace you pick." />
</ul>
<div className="mt-6 flex items-center gap-2">
<button
type="button"
onClick={onDeny}
disabled={busy}
className="h-10 px-4 rounded-md border border-slate-200 hover:border-slate-300 text-[13px] text-slate-700 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
<XIcon className="w-3.5 h-3.5" /> Decline
</button>
<button
type="button"
onClick={onApprove}
disabled={!orgId || busy}
className="flex-1 h-10 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13.5px] font-medium inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-60"
>
{approving ? <Loader2Icon className="w-4 h-4 animate-spin" /> : <CheckIcon className="w-4 h-4" />}
Authorize terminal
</button>
</div>
</>
)}
</div>
);
}
function Perm({ icon: Icon, title, body }: { icon: React.ComponentType<{ className?: string }>; title: string; body: string }) {
return (
<li className="rounded-lg border border-slate-200 px-3 py-2.5 flex items-start gap-2.5">
<Icon className="w-4 h-4 mt-0.5 text-sky-600 shrink-0" />
<span>
<span className="block text-[12px] font-semibold text-slate-900">{title}</span>
<span className="block text-[12px] text-slate-500 leading-relaxed">{body}</span>
</span>
</li>
);
}
function DoneStep({ approved, info, orgName, onAnother }: { approved: boolean; info: CLIAuthCode; orgName?: string; onAnother: () => void }) {
return (
<div className="flex flex-col items-center text-center py-2">
<motion.span
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 380, damping: 20, delay: 0.1 }}
className={`size-16 rounded-full inline-flex items-center justify-center ${approved ? "bg-emerald-50 text-emerald-600" : "bg-slate-100 text-slate-500"}`}
>
{approved ? <CheckIcon className="w-8 h-8" /> : <XIcon className="w-8 h-8" />}
</motion.span>
<h1 className="mt-5 text-[24px] sm:text-[28px] font-semibold tracking-[-0.03em] leading-[1.1] text-slate-900">
{approved ? "Terminal authorized" : "Request declined"}
</h1>
<p className="mt-2.5 text-[13.5px] text-slate-500 leading-relaxed max-w-sm">
{approved ? (
<>
<span className="font-medium text-slate-700">{info.hostname || "Your terminal"}</span> picks this up on its own within a few seconds
{orgName ? (
<>
{" "}
and is now signed in to <span className="font-medium text-slate-700">{orgName}</span>.
</>
) : (
"."
)}{" "}
You can close this tab.
</>
) : (
"Nothing was created. The terminal will show that the request was declined."
)}
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
<Link
to="/app/api-keys"
className="h-10 px-4 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[13.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
API keys <ArrowRightIcon className="w-3.5 h-3.5" />
</Link>
<a
href="https://docs.warmbly.com/api/cli/"
target="_blank"
rel="noreferrer"
className="h-10 px-4 rounded-md border border-slate-200 hover:border-slate-300 text-slate-800 text-[13.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
CLI docs <ExternalLinkIcon className="w-3.5 h-3.5" />
</a>
</div>
<button type="button" onClick={onAnother} className="mt-5 text-[12px] text-slate-500 hover:text-slate-900 transition-colors">
Authorize another terminal
</button>
</div>
);
}
+1
View File
@@ -66,6 +66,7 @@ const ROUTE_TITLES: Record<string, string> = {
"/app/settings/profile": "Profile",
"/app/settings/warmbly-cloud": "Warmbly Cloud",
"/connect": "Connect",
"/cli": "Authorize CLI",
"/app/settings/notifications": "Notifications",
"/app/settings/security": "Security",
"/app/settings/members": "Members",
@@ -0,0 +1,22 @@
// /auth/cli/* — a signed-in member reviews the code a CLI is showing and
// authorizes it into one of their workspaces, which mints the API key.
import Request from "@/lib/api/client/Request";
import type { CLIAuthCode } from "@/lib/api/models/app/cliauth/CLIAuth";
export async function describeCLIAuthCode(code: string): Promise<CLIAuthCode> {
return await Request<CLIAuthCode>({ method: "GET", url: `/auth/cli/codes/${encodeURIComponent(code)}`, authorization: true });
}
export async function approveCLIAuthCode(code: string, organizationId: string): Promise<CLIAuthCode> {
return await Request<CLIAuthCode>({
method: "POST",
url: `/auth/cli/codes/${encodeURIComponent(code)}/approve`,
data: { organization_id: organizationId },
authorization: true,
});
}
export async function denyCLIAuthCode(code: string): Promise<void> {
await Request<void>({ method: "POST", url: `/auth/cli/codes/${encodeURIComponent(code)}/deny`, authorization: true });
}
@@ -0,0 +1,28 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { approveCLIAuthCode, denyCLIAuthCode, describeCLIAuthCode } from "@/lib/api/client/app/cliauth/cliAuth";
export const CLI_AUTH_KEY = ["cli-auth"];
export function useCLIAuthCode(code: string) {
return useQuery({
queryKey: [...CLI_AUTH_KEY, "code", code],
queryFn: () => describeCLIAuthCode(code),
enabled: code.length === 9,
retry: false,
staleTime: 0,
});
}
export function useApproveCLIAuthCode() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ code, organizationId }: { code: string; organizationId: string }) => approveCLIAuthCode(code, organizationId),
// The approval mints a key, so the API keys list is stale everywhere.
onSuccess: () => void qc.invalidateQueries({ queryKey: ["api-keys"] }),
});
}
export function useDenyCLIAuthCode() {
return useMutation({ mutationFn: (code: string) => denyCLIAuthCode(code) });
}
@@ -0,0 +1,18 @@
// /auth/cli/* — the browser half of `warmbly auth login`.
export type CLIAuthCodeStatus = "pending" | "approved" | "claimed" | "denied";
export interface CLIAuthCode {
id: string;
user_code: string;
client_name: string;
hostname: string;
cli_version: string;
scopes: number;
scope_names: string[];
status: CLIAuthCodeStatus;
organization_id?: string;
api_key_id?: string;
expires_at: string;
created_at: string;
}
+6
View File
@@ -89,6 +89,7 @@ import OnboardingPage from './app/onboarding/page';
import SelectOrgPage from './app/select-org/page';
import InviteAcceptPage from './app/invite/page';
import ConnectPage from './app/connect/page';
import CLIAuthPage from './app/cli/page';
import CloudOAuthDonePage from './app/cloud-oauth/done/page';
import WarmblyCloudSettingsPage from './app/app/settings/warmbly-cloud/page';
import SetupPage from './app/setup/page';
@@ -209,6 +210,11 @@ const router = createBrowserRouter([
path: "connect",
element: <ConnectPage />,
},
{
// Where `warmbly auth login` sends the browser to approve its code.
path: "cli",
element: <CLIAuthPage />,
},
{
// Where Warmbly Cloud sends the Google/Microsoft popup back to on a linked instance.
path: "cloud-oauth/done",