feat(admin): session probe, self-hosted signup and the relink cap

Adds GET /auth/me so the admin site's route guards can know who is signed
in, POST /auth/signup for self-hosted customers, and max_relinks on the
account payload so the UI never hardcodes a rule the backend enforces.

Signup follows sitesvc's proven shape: honeypot answered as success, a
generic 201 when the address already exists, and nothing usable until the
emailed link is opened.

Also fixes a lockout found while verifying it. When the verification email
failed, the account was rolled back but the customer_users row survived --
an orphan that can never be signed in to and that holds the unique index on
email, so every later signup with that address got a cheerful 201 and the
customer was locked out of their own address with no visible error.
CreateCustomerUser now undoes its own insert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 20:48:09 +01:00
co-authored by Claude Opus 5
parent 79afcc2e16
commit 4bb7400b8e
6 changed files with 153 additions and 17 deletions
+25 -1
View File
@@ -37,6 +37,24 @@ func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) {
return &inst, true
}
// getMe reports who the caller is, for route guards in the UI.
//
// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a
// truthful 401 to redirect on, not an error page. It reveals nothing a caller
// does not already possess, because it only ever describes their own cookie.
func getMe(c *gin.Context) {
s := auth.Load(c)
if s == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"})
return
}
c.JSON(http.StatusOK, gin.H{
"kind": s.Kind,
"email": s.Email,
"account_id": s.AccountID,
})
}
func getAccount(c *gin.Context) {
s := auth.Current(c)
ctx := c.Request.Context()
@@ -58,7 +76,13 @@ func getAccount(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances})
c.JSON(http.StatusOK, gin.H{
"account": acct,
"instances": instances,
// Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a
// second source of truth for a rule the backend enforces.
"max_relinks": models.MaxRelinksPerTerm,
})
}
func linkInstance(c *gin.Context) {
+2
View File
@@ -30,6 +30,8 @@ func Routes(cfg config.Config) http.Handler {
r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/verify", auth.HandleVerify)
r.GET("/auth/me", getMe)
r.POST("/auth/signup", auth.HandleSignup)
cust := r.Group("/api")
cust.Use(auth.RequireCustomer())