mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 00:04:27 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user