mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 00:02:12 +00:00
Merge remote-tracking branch 'origin/main' into fix/sso-link-existing-password-account
# Conflicts: # docs/content/docs/guides/security.mdx
This commit is contained in:
@@ -231,6 +231,16 @@ Everything else in section 3 of the evidence pack rests on this: no identifier f
|
||||
- the realtime websocket checks the browser's `Origin` against `CHECK_ORIGIN_HOSTS`. Non-browser clients send no origin and are unaffected. Adding a first-party origin means adding it to that list in every environment
|
||||
- webhook targets stay HTTPS and HMAC-signed, and SSRF-prone destinations are refused. Only a self-hosted or development instance may opt out
|
||||
|
||||
### Input that other people see
|
||||
|
||||
Anything one person types that Warmbly later shows to someone else is content injection waiting to happen, and platform email is the worst case: a mail client turns anything shaped like an address into a live link, sent under Warmbly's own domain. `html/template` escaping stops markup, not that. So:
|
||||
|
||||
- **every name a person chooses goes through `internal/pkg/displayname`**: first and last names, workspace names, and any new name-like field that can reach another person. It refuses links, web addresses, email addresses, hostnames and IPs (after folding full-width and ideographic dots), control, invisible and bidi characters, markup characters and stacked combining marks, and it bounds length by `Kind`. The refusal is `400 invalid_name`, documented in `api/error-codes.mdx`
|
||||
- **the server is the authority and the check sits at every write**, not only the one the dashboard uses: the handler or service behind registration, setup, onboarding, profile, org create and rename, the admin panel, `warmblyctl` and an org-transfer import. A new path that writes one of these fields calls the same package. `web/src/lib/displayName.ts` mirrors the rules so a form can explain a refusal before the request, and it is never the only check
|
||||
- **a value nobody can be asked to correct is cleaned, not refused**: a name from an identity provider or an email local part goes through `displayname.Clean`/`FromEmail`, which drops what fails, so a hostile IdP claim costs the user a name, not a sign-in
|
||||
- **a stored value is untrusted at render time too.** Rows written before a rule existed are still in the database, so anything interpolated into an email body or subject goes through `displayname.Displayable` (or `FullName`) with a neutral fallback ("A team member", "Your workspace")
|
||||
- **tighten a rule in both places and in the docs together**: Go package, `displayName.ts`, their tests, and the `invalid_name` section of `api/error-codes.mdx`
|
||||
|
||||
### Errors, logging and data exposure
|
||||
|
||||
- a server-class (`Internal`) error answers the caller with one fixed sentence and a request id. The real message is logged against that id. `errx.NewPublic` is the narrow exception, for a message an operator can act on, and never for one built from an underlying error
|
||||
|
||||
@@ -208,6 +208,7 @@ export default function TestersPage() {
|
||||
<input
|
||||
className="w-full h-8 rounded-md border border-border bg-background px-2 text-xs"
|
||||
placeholder="Workspace name (optional)"
|
||||
maxLength={64}
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
/>
|
||||
|
||||
+6
-11
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/argon2"
|
||||
"github.com/warmbly/warmbly/internal/pkg/crypt"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
)
|
||||
|
||||
// resetSessionKeyPrefix must match getResetPasswordSessionKey in
|
||||
@@ -94,6 +95,9 @@ func runUserCreate(ctx context.Context, args []string) error {
|
||||
if *noOrg && strings.TrimSpace(*orgName) != "" {
|
||||
return errors.New("--org and --no-org contradict each other. Pass one or neither.")
|
||||
}
|
||||
if _, nerr := displayname.Validate("--org", *orgName, displayname.Workspace, true); nerr != nil {
|
||||
return errors.New(nerr.Message)
|
||||
}
|
||||
|
||||
c, err := connect(ctx)
|
||||
if err != nil {
|
||||
@@ -135,9 +139,9 @@ func runUserCreate(ctx context.Context, args []string) error {
|
||||
changed := []string{fmt.Sprintf("Created account %s (id %s)", created.Email, created.ID)}
|
||||
|
||||
if !*noOrg {
|
||||
name := strings.TrimSpace(*orgName)
|
||||
name := displayname.Normalize(*orgName)
|
||||
if name == "" {
|
||||
name = defaultOrgName(created.FirstName)
|
||||
name = displayname.DefaultWorkspace(created.FirstName)
|
||||
}
|
||||
org, oerr := c.orgService().Create(ctx, created.ID, name)
|
||||
if oerr != nil {
|
||||
@@ -520,15 +524,6 @@ func lookupUser(ctx context.Context, c *conn, address string) (*models.User, err
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// defaultOrgName mirrors the bootstrap owner's naming so an account created
|
||||
// here is indistinguishable from one claimed through the setup link.
|
||||
func defaultOrgName(firstName string) string {
|
||||
if firstName == "" {
|
||||
return "My Organization"
|
||||
}
|
||||
return firstName + "'s Organization"
|
||||
}
|
||||
|
||||
func adminRoleNames() []string {
|
||||
return []string{
|
||||
string(models.AdminRoleSuper),
|
||||
|
||||
@@ -93,6 +93,7 @@ Returned when the request cannot be processed due to invalid syntax.
|
||||
| `selection_too_large` | A `"all": true` bulk selection resolved to more than `50,000` rows. Narrow the filter and run it in parts; nothing was changed |
|
||||
| `invalid_filter` | A task filter carried an id that is not one: `assigned_to`, `contact_id` and `deal_id` name records, and are matched against id columns. Sent by `POST /crm/tasks/search`, `POST /crm/tasks/summary`, and the `filters` of a `"all": true` bulk selection |
|
||||
| `invalid_setting` | `PATCH /outreach/settings` (or a campaign's advanced settings) carried a value outside the documented vocabulary, for example a `reply_intent.crm_task_intents` entry that is not a reply intent |
|
||||
| `invalid_slug` | `PATCH /organizations/current` was given a `slug` that is not 2 to 80 lowercase letters, numbers or dashes starting and ending with a letter or number |
|
||||
| `no_organization` | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry |
|
||||
|
||||
#### Password refusals
|
||||
@@ -113,6 +114,30 @@ A password must be 8 to 128 characters. There is no composition rule, but it is
|
||||
}
|
||||
```
|
||||
|
||||
#### Name refusals
|
||||
|
||||
| `code` | Status | Meaning |
|
||||
|--------|--------|---------|
|
||||
| `invalid_name` | 400 | A first name, last name, workspace name or company name broke the naming rules. The `message` names the field and the rule |
|
||||
|
||||
Names are shown to other people, including in invitation and notification emails, so they carry plain text only. The same rules apply to the dashboard, the API, the setup page and `warmblyctl`:
|
||||
|
||||
- a first or last name is at most 50 characters and contains a letter; a workspace or company name is at most 64 characters and contains a letter or a number
|
||||
- no links, web addresses or email addresses: nothing with `@`, `//`, `www.` or a scheme such as `https:`, no hostname such as `example.com`, and no IP address. Full-width and ideographic dots count as dots
|
||||
- no control characters, invisible formatting characters (zero-width and bidirectional overrides), `<`, `>`, `` ` `` or `\`, and no more than three stacked combining marks
|
||||
- leading and trailing spaces are trimmed and runs of spaces become one, and that is the form stored
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "First name cannot contain a link, web address or email address.",
|
||||
"code": "invalid_name",
|
||||
"request_id": "4bbbd1b2-8f86-47dd-8a7f-9476501ad20e"
|
||||
}
|
||||
```
|
||||
|
||||
A name from Google, Apple or a single sign-on provider that breaks these rules is dropped rather than refusing the sign-in, and you are asked for it during onboarding.
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
Returned when authentication fails.
|
||||
|
||||
@@ -191,7 +191,7 @@ Auth: public (not available to API keys).
|
||||
|
||||
`POST /auth/reset-password/confirm`
|
||||
|
||||
Sets a new password using the emailed reset code (carried inside the signed `session` token).
|
||||
Sets a new password using the emailed reset code (carried inside the signed `session` token). A reset token is single-use and bound to the password it was requested against: once the password is written by any path (this endpoint, the signed-in change, or an operator reset), every reset token issued before that write answers `401` however long its own expiry has left.
|
||||
|
||||
Auth: public (not available to API keys).
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
|
||||
| `WARMBLY_BOOTSTRAP_EMAIL` | First owner's address, read only while the users table is empty | unset | yes |
|
||||
| `WARMBLY_BOOTSTRAP_PASSWORD_HASH` | Argon2 PHC string for that owner. Preferred over the plaintext form | unset | yes |
|
||||
| `WARMBLY_BOOTSTRAP_PASSWORD` | Plaintext convenience form. Warns at boot, and leaves a password in your process environment | unset | yes |
|
||||
| `WARMBLY_BOOTSTRAP_ORG` | Name of the organization created with that owner | derived from the name | yes |
|
||||
| `WARMBLY_BOOTSTRAP_ORG` | Name of the organization created with that owner. A value that breaks the [naming rules](/api/error-codes/#name-refusals) is ignored with a warning | derived from the name | yes |
|
||||
| `TWOFA_SECRET` | Key that encrypts stored TOTP secrets. Falls back to `AUTH_SECRET`, so existing deployments keep working; rotating it invalidates every enrolled TOTP secret | `AUTH_SECRET` | yes |
|
||||
| `WEBAUTHN_RP_ID` | Passkey relying party id. Derived from `APP_URL` when unset. Changing it invalidates every enrolled passkey | derived | yes |
|
||||
| `WEBAUTHN_RP_ORIGINS` | Origins accepted for passkey ceremonies | derived from `APP_URL` | yes |
|
||||
|
||||
@@ -128,7 +128,7 @@ All of these are read **only while the users table is empty**, so they are a no-
|
||||
| It gets | Detail |
|
||||
|---|---|
|
||||
| An owner account | Owner of a new organization, which is a membership status rather than a role |
|
||||
| An organization | Named from `WARMBLY_BOOTSTRAP_ORG`, or derived from the owner's name |
|
||||
| An organization | Named from `WARMBLY_BOOTSTRAP_ORG` when it is a valid workspace name, otherwise derived from the owner's name |
|
||||
| A trial | Irrelevant when `BILLING_PROVIDER=none`, which is the self-host default and unlocks everything. The dashboard header shows a `Self-hosted` badge instead of a plan or trial, and the billing and referral settings pages are hidden |
|
||||
| Every platform admin bit | So the same account signs in to the dashboard on `:5173` and the admin panel on `:5174` |
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ A first sign-in with a new address via Google or Apple creates your account, wor
|
||||
|
||||
If the address already has a Warmbly account with a password, the first Google, Apple or single sign-on with it asks for that password before the provider is attached to the account. The provider confirms the address is yours; the password confirms the account is. Once linked, later sign-ins with that provider go straight through. An account that never set a password (created through a provider or an invitation) is linked on the first sign-in.
|
||||
|
||||
Accounts created through an external sign-in provider have no password initially. Trying password sign-in returns the same invalid-credentials response as an incorrect password. Continue with your provider, or use **Forgot password** to set a password before signing in with it.
|
||||
Accounts created through an external sign-in provider have no password initially. Trying password sign-in returns the same invalid-credentials response as an incorrect password. Continue with your provider, or use **Forgot password** to set a password before signing in with it. A reset link works once and only for the password it was requested against: changing your password by any route, including using another reset link, makes every earlier link invalid before it expires.
|
||||
|
||||
<Callout type="info" title="Passwordless by design">
|
||||
A passkey is tied to your device and unlocked with biometrics or a PIN, so it already proves both something you have and something you are or know. That is why it skips the emailed code.
|
||||
@@ -90,6 +90,10 @@ There is no rule about mixing upper case, digits and symbols. A long passphrase
|
||||
|
||||
Repeated wrong passwords are counted per account, not just per device, so guessing one account from many addresses does not buy an attacker more attempts. After ten failures the account stops accepting password attempts for an hour. Signing in correctly clears the count.
|
||||
|
||||
## Names
|
||||
|
||||
Your name and your workspace's name appear in emails Warmbly sends to other people, such as invitations. They are plain text only: a name that contains a link, a web address, an email address or invisible formatting characters is refused, and the field tells you why. The full rules are in [Name refusals](/api/error-codes/#name-refusals).
|
||||
|
||||
## Password and alerts
|
||||
|
||||
Change your password under **Password**: current password, then a new one. **Changing it signs out every other device automatically**, while the device you change it on stays in. Accounts that only use Google, Apple, or a passkey have no password to change.
|
||||
|
||||
@@ -99,6 +99,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap
|
||||
| Scheduled deletions | A pending deletion from the source must never follow the workspace to its new home |
|
||||
| Failure and delivery counters | A webhook endpoint's failure streak and auto-disable state, and whether a notification's email already went out, describe what happened on the source. They start fresh, so an endpoint is not pre-disabled on the new instance and a notification is not re-sent |
|
||||
| Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history |
|
||||
| An invalid workspace name | An archive's workspace name is applied only when it passes the same [naming rules](/api/error-codes/#name-refusals) as a rename. Otherwise the destination keeps its own |
|
||||
| Personal list layouts | Which columns each member shows on the contacts list and how they sort it belongs to the person, not the workspace. Everyone starts from the default view on the new instance and picks their columns again |
|
||||
|
||||
An import runs as one transaction. If anything fails, nothing lands and the workspace is untouched.
|
||||
|
||||
@@ -20902,13 +20902,21 @@
|
||||
},
|
||||
"UpdateProfileRequest": {
|
||||
"type": "object",
|
||||
"description": "Editable profile fields for the authenticated user.",
|
||||
"description": "Editable profile fields for the authenticated user. Both names are plain text: no links, web addresses, email addresses or invisible formatting characters. A refused name answers 400 with code `invalid_name`.",
|
||||
"required": [
|
||||
"first_name",
|
||||
"last_name"
|
||||
],
|
||||
"properties": {
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ require (
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
google.golang.org/api v0.264.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1
|
||||
@@ -329,7 +330,6 @@ require (
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/pkg/argon2"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
)
|
||||
|
||||
// A tester account is one an operator hands to somebody outside the team: a
|
||||
@@ -96,6 +97,11 @@ func (h *Handler) AdminCreateTester(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, nerr := displayname.Validate("Workspace name", req.OrgName, displayname.Workspace, true); nerr != nil {
|
||||
errx.JSON(c, nerr)
|
||||
return
|
||||
}
|
||||
|
||||
if req.OrgID != nil && req.RoleID == nil {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, "joining an existing workspace needs a role, so the access granted is one somebody chose"))
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/config"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
)
|
||||
|
||||
type completeOnboardingRequest struct {
|
||||
@@ -53,15 +54,12 @@ func (h *Handler) CompleteOnboarding(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.FirstName == "" || req.LastName == "" {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "First name and last name are required."))
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.FirstName) > 50 || len(req.LastName) > 50 {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "Name must be 50 characters or less."))
|
||||
first, last, xerr := validatePersonName(req.FirstName, req.LastName)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
req.FirstName, req.LastName = first, last
|
||||
|
||||
if !validReferralSources[req.ReferralSource] {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "Invalid referral source."))
|
||||
@@ -108,14 +106,12 @@ func (h *Handler) UpdateUserProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.FirstName == "" || req.LastName == "" {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "First name and last name are required."))
|
||||
return
|
||||
}
|
||||
if len(req.FirstName) > 50 || len(req.LastName) > 50 {
|
||||
errx.Handle(c, errx.New(errx.BadRequest, "Name must be 50 characters or less."))
|
||||
first, last, xerr := validatePersonName(req.FirstName, req.LastName)
|
||||
if xerr != nil {
|
||||
errx.Handle(c, xerr)
|
||||
return
|
||||
}
|
||||
req.FirstName, req.LastName = first, last
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
uid, err := uuid.Parse(userID)
|
||||
@@ -167,3 +163,17 @@ func (h *Handler) UpdateSendPreferences(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"undo_send_seconds": req.UndoSendSeconds})
|
||||
}
|
||||
|
||||
// validatePersonName applies the display-name rules to a first and last name,
|
||||
// returning their stored forms.
|
||||
func validatePersonName(first, last string) (string, string, *errx.Error) {
|
||||
first, xerr := displayname.Validate("First name", first, displayname.Person, false)
|
||||
if xerr != nil {
|
||||
return "", "", xerr
|
||||
}
|
||||
last, xerr = displayname.Validate("Last name", last, displayname.Person, false)
|
||||
if xerr != nil {
|
||||
return "", "", xerr
|
||||
}
|
||||
return first, last, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/notify/templates"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
)
|
||||
|
||||
// CreateOrganization creates a new organization
|
||||
@@ -209,17 +210,16 @@ func (h *Handler) InviteMember(c *gin.Context) {
|
||||
// Get organization name for email
|
||||
org, _ := h.OrganizationService.Get(c.Request.Context(), *orgID)
|
||||
orgName := "your organization"
|
||||
if org != nil {
|
||||
orgName = org.Name
|
||||
if org != nil && displayname.Displayable(org.Name) != "" {
|
||||
orgName = displayname.Displayable(org.Name)
|
||||
}
|
||||
|
||||
// Get inviter name
|
||||
// Names predating the display-name rules fall back rather than render.
|
||||
inviter, _ := h.UserService.GetUser(c.Request.Context(), userID)
|
||||
inviterName := "A team member"
|
||||
if inviter != nil && inviter.FirstName != "" {
|
||||
inviterName = inviter.FirstName
|
||||
if inviter.LastName != "" {
|
||||
inviterName += " " + inviter.LastName
|
||||
if inviter != nil {
|
||||
if name := displayname.FullName(inviter.FirstName, inviter.LastName); name != "" {
|
||||
inviterName = name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@ func (h *Handler) AcceptInvitation(c *gin.Context) {
|
||||
// joiner). Detached + best-effort: a notification hiccup must not fail
|
||||
// the accept.
|
||||
if h.NotificationService != nil {
|
||||
joiner := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName))
|
||||
joiner := displayname.FullName(user.FirstName, user.LastName)
|
||||
if joiner == "" {
|
||||
joiner = user.Email
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
)
|
||||
|
||||
// GetSubscription returns the current organization's subscription
|
||||
@@ -346,6 +348,8 @@ type EnterpriseInquiryRequest struct {
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
const enterpriseNotesMaxLength = 2000
|
||||
|
||||
// SubmitEnterpriseInquiry submits an enterprise pricing inquiry
|
||||
func (h *Handler) SubmitEnterpriseInquiry(c *gin.Context) {
|
||||
var req EnterpriseInquiryRequest
|
||||
@@ -354,9 +358,24 @@ func (h *Handler) SubmitEnterpriseInquiry(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
company, xerr := displayname.Validate("Company name", req.CompanyName, displayname.Workspace, false)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
contact, xerr := displayname.Validate("Contact name", req.ContactName, displayname.Person, false)
|
||||
if xerr != nil {
|
||||
errx.JSON(c, xerr)
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Notes)) > enterpriseNotesMaxLength {
|
||||
errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("Notes must be %d characters or less.", enterpriseNotesMaxLength)))
|
||||
return
|
||||
}
|
||||
|
||||
inquiry := &models.EnterpriseInquiry{
|
||||
CompanyName: req.CompanyName,
|
||||
ContactName: req.ContactName,
|
||||
CompanyName: company,
|
||||
ContactName: contact,
|
||||
ContactEmail: req.ContactEmail,
|
||||
EstimatedVolume: req.EstimatedVolume,
|
||||
TeamSize: req.TeamSize,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
"github.com/warmbly/warmbly/internal/pkg/idtoken"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
@@ -233,6 +234,9 @@ func (s *authService) createExternalUser(ctx context.Context, email *mail.Addres
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A provider-asserted name the rules refuse is dropped, never a failed sign-in.
|
||||
firstName = displayname.Clean(firstName, displayname.Person)
|
||||
lastName = displayname.Clean(lastName, displayname.Person)
|
||||
if firstName != "" {
|
||||
// Provider-asserted name beats CreateUser's email local-part default.
|
||||
if perr := s.userRepository.UpdateProfile(ctx, u.ID, firstName, lastName); perr == nil {
|
||||
@@ -253,10 +257,7 @@ func (s *authService) createExternalUser(ctx context.Context, email *mail.Addres
|
||||
|
||||
var org *models.Organization
|
||||
if s.organizationService != nil {
|
||||
orgName := u.FirstName + "'s Organization"
|
||||
if u.FirstName == "" {
|
||||
orgName = "My Organization"
|
||||
}
|
||||
orgName := displayname.DefaultWorkspace(u.FirstName)
|
||||
var orgErr *errx.Error
|
||||
org, orgErr = s.organizationService.Create(ctx, u.ID, orgName)
|
||||
if orgErr != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/analytics"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
"github.com/warmbly/warmbly/internal/pkg/signuprisk"
|
||||
)
|
||||
|
||||
@@ -101,10 +102,7 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash s
|
||||
// Auto-create organization for new user
|
||||
var org *models.Organization
|
||||
if s.organizationService != nil {
|
||||
orgName := u.FirstName + "'s Organization"
|
||||
if u.FirstName == "" {
|
||||
orgName = "My Organization"
|
||||
}
|
||||
orgName := displayname.DefaultWorkspace(u.FirstName)
|
||||
var orgErr *errx.Error
|
||||
org, orgErr = s.organizationService.Create(ctx, u.ID, orgName)
|
||||
if orgErr != nil {
|
||||
@@ -238,7 +236,7 @@ func (s *authService) notifyOperatorSignup(u *models.User, workspace string) {
|
||||
"A new account finished signing up.",
|
||||
map[string]string{
|
||||
"Email": u.Email,
|
||||
"Name": strings.TrimSpace(u.FirstName + " " + u.LastName),
|
||||
"Name": displayname.FullName(u.FirstName, u.LastName),
|
||||
"Workspace": workspace,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
tokenpkg "github.com/warmbly/warmbly/internal/app/token"
|
||||
@@ -158,6 +159,18 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw
|
||||
return errx.ErrToken
|
||||
}
|
||||
|
||||
// A link is only good for the password it was requested against. Once the
|
||||
// password has been written by any path (this flow, the signed-in change,
|
||||
// the operator CLI), every link issued before that write is dead, however
|
||||
// long its own expiry has left.
|
||||
changedAt, err := s.authRepository.PasswordChangedAt(ctx, sess.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resetLinkPredatesPassword(sess.IssuedAt, changedAt) {
|
||||
return errx.ErrToken
|
||||
}
|
||||
|
||||
if err := s.deletePasswordResetSession(ctx, sess.SessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -194,6 +207,20 @@ func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPassw
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetLinkPredatesPassword reports whether a reset token was issued no later
|
||||
// than the last password write. JWT iat is whole seconds and the write is
|
||||
// stamped by Postgres at microseconds, so a token minted in the same second as
|
||||
// the change is refused too: fail closed, the person asks for a new link.
|
||||
func resetLinkPredatesPassword(issuedAt *jwt.NumericDate, changedAt *time.Time) bool {
|
||||
if changedAt == nil {
|
||||
return false
|
||||
}
|
||||
if issuedAt == nil {
|
||||
return true
|
||||
}
|
||||
return !issuedAt.Time.After(*changedAt)
|
||||
}
|
||||
|
||||
// ChangePassword updates a logged-in user's password. It verifies the current
|
||||
// password first (so a hijacked but unattended session can't silently change
|
||||
// it), rejects OAuth-only accounts, and enforces the password policy.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// A reset link is bound to the password it was requested against: once any
|
||||
// path writes a new one, every link issued at or before that write is dead,
|
||||
// whatever its own expiry says.
|
||||
func TestResetLinkPredatesPassword(t *testing.T) {
|
||||
changed := time.Date(2026, 9, 21, 12, 0, 0, 500_000_000, time.UTC)
|
||||
at := func(t time.Time) *jwt.NumericDate { return jwt.NewNumericDate(t) }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
issuedAt *jwt.NumericDate
|
||||
changedAt *time.Time
|
||||
stale bool
|
||||
}{
|
||||
{"never changed", at(changed.Add(-time.Hour)), nil, false},
|
||||
{"issued before the change", at(changed.Add(-time.Hour)), &changed, true},
|
||||
{"issued after the change", at(changed.Add(time.Hour)), &changed, false},
|
||||
// iat is whole seconds, so a token minted in the same second as the
|
||||
// change cannot prove it came after it.
|
||||
{"issued in the same second", at(changed), &changed, true},
|
||||
{"issued the next second", at(changed.Add(time.Second)), &changed, false},
|
||||
{"no issue time at all", nil, &changed, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resetLinkPredatesPassword(tc.issuedAt, tc.changedAt); got != tc.stale {
|
||||
t.Fatalf("stale = %v, want %v", got, tc.stale)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/argon2"
|
||||
"github.com/warmbly/warmbly/internal/pkg/crypt"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
@@ -124,10 +125,7 @@ func (s *Service) createOwner(ctx context.Context, address string) error {
|
||||
return fmt.Errorf("bootstrap: saving the owner: %w", err)
|
||||
}
|
||||
|
||||
orgName := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_ORG"))
|
||||
if orgName == "" {
|
||||
orgName = defaultOrgName(u.FirstName)
|
||||
}
|
||||
orgName := bootstrapOrgName(u.FirstName)
|
||||
org, orgErr := s.orgSvc.Create(ctx, u.ID, orgName)
|
||||
if orgErr != nil {
|
||||
return fmt.Errorf("bootstrap: creating the organization: %w", orgErr)
|
||||
@@ -264,6 +262,14 @@ func (s *Service) Claim(ctx context.Context, token, address, password, firstName
|
||||
if perr := crypt.PasswordError(password); perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
firstName, nerr := displayname.Validate("Name", firstName, displayname.Person, true)
|
||||
if nerr != nil {
|
||||
return nil, nerr
|
||||
}
|
||||
lastName, nerr = displayname.Validate("Last name", lastName, displayname.Person, true)
|
||||
if nerr != nil {
|
||||
return nil, nerr
|
||||
}
|
||||
|
||||
// Refuse on an instance that already has accounts, even with a valid
|
||||
// token: a stale link out of an old log must never mint a second owner.
|
||||
@@ -313,10 +319,7 @@ func (s *Service) Claim(ctx context.Context, token, address, password, firstName
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgName := strings.TrimSpace(os.Getenv("WARMBLY_BOOTSTRAP_ORG"))
|
||||
if orgName == "" {
|
||||
orgName = defaultOrgName(u.FirstName)
|
||||
}
|
||||
orgName := bootstrapOrgName(u.FirstName)
|
||||
org, orgErr := s.orgSvc.Create(ctx, u.ID, orgName)
|
||||
if orgErr != nil {
|
||||
errs.CaptureException(orgErr)
|
||||
@@ -335,11 +338,16 @@ func (s *Service) Claim(ctx context.Context, token, address, password, firstName
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func defaultOrgName(firstName string) string {
|
||||
if firstName == "" {
|
||||
return "My Organization"
|
||||
// bootstrapOrgName is WARMBLY_BOOTSTRAP_ORG when it is a valid workspace name.
|
||||
func bootstrapOrgName(firstName string) string {
|
||||
env := os.Getenv("WARMBLY_BOOTSTRAP_ORG")
|
||||
if name := displayname.Clean(env, displayname.Workspace); name != "" {
|
||||
return name
|
||||
}
|
||||
return firstName + "'s Organization"
|
||||
if strings.TrimSpace(env) != "" {
|
||||
log.Printf("Warning: WARMBLY_BOOTSTRAP_ORG is not a valid workspace name; using the default.")
|
||||
}
|
||||
return displayname.DefaultWorkspace(firstName)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/notify"
|
||||
"github.com/warmbly/warmbly/internal/notify/templates"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
@@ -458,8 +459,8 @@ func (s *service) sendOrgScheduledEmail(ctx context.Context, org *models.Organiz
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("%s scheduled for deletion", org.Name)
|
||||
body, err := templates.GenerateOrgDeletionScheduledHTML(org.Name, d.ExecuteAfter, d.GraceDays, s.frontendBaseURL+orgDangerZonePath)
|
||||
subject := fmt.Sprintf("%s scheduled for deletion", emailOrgName(org))
|
||||
body, err := templates.GenerateOrgDeletionScheduledHTML(emailOrgName(org), d.ExecuteAfter, d.GraceDays, s.frontendBaseURL+orgDangerZonePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -474,8 +475,8 @@ func (s *service) sendOrgCancelledEmail(ctx context.Context, org *models.Organiz
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("Deletion cancelled for %s", org.Name)
|
||||
body, err := templates.GenerateOrgDeletionCancelledHTML(org.Name, d.ExecuteAfter)
|
||||
subject := fmt.Sprintf("Deletion cancelled for %s", emailOrgName(org))
|
||||
body, err := templates.GenerateOrgDeletionCancelledHTML(emailOrgName(org), d.ExecuteAfter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -531,7 +532,7 @@ func (s *service) buildReminder(ctx context.Context, d *models.ScheduledDeletion
|
||||
return nil, "", ""
|
||||
}
|
||||
recipients = s.orgRecipients(ctx, org)
|
||||
resourceName = org.Name
|
||||
resourceName = emailOrgName(org)
|
||||
case models.DeletionResourceUser:
|
||||
user, _ := s.userRepo.GetUser(ctx, d.ResourceID)
|
||||
if user == nil {
|
||||
@@ -613,8 +614,7 @@ func nilIfEmpty(s string) *string {
|
||||
}
|
||||
|
||||
func displayName(u *models.User) string {
|
||||
name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
|
||||
if name != "" {
|
||||
if name := displayname.FullName(u.FirstName, u.LastName); name != "" {
|
||||
return name
|
||||
}
|
||||
return u.Email
|
||||
@@ -623,12 +623,20 @@ func displayName(u *models.User) string {
|
||||
// firstNameOrEmail is the friendly greeting name for deletion emails:
|
||||
// the user's first name when set, otherwise their email address.
|
||||
func firstNameOrEmail(u *models.User) string {
|
||||
if strings.TrimSpace(u.FirstName) != "" {
|
||||
return u.FirstName
|
||||
if name := displayname.Displayable(u.FirstName); name != "" {
|
||||
return name
|
||||
}
|
||||
return u.Email
|
||||
}
|
||||
|
||||
// emailOrgName is the workspace name as deletion emails show it.
|
||||
func emailOrgName(org *models.Organization) string {
|
||||
if name := displayname.Displayable(org.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
return "Your workspace"
|
||||
}
|
||||
|
||||
// orgRecipients returns every member email for an org, owner first.
|
||||
// Owner is always included even if GetMembers somehow fails to load
|
||||
// them (defensive — the owner is the only person who can actually
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/observability/errs"
|
||||
"github.com/warmbly/warmbly/internal/pkg/crypt"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
@@ -254,6 +256,11 @@ func NewService(
|
||||
|
||||
// Create creates a new organization and adds the user as owner
|
||||
func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name string) (*models.Organization, *errx.Error) {
|
||||
name, nerr := displayname.Validate("Workspace name", name, displayname.Workspace, false)
|
||||
if nerr != nil {
|
||||
return nil, nerr
|
||||
}
|
||||
|
||||
// Ban-scope enforcement (migration 000045). Block new workspace
|
||||
// creation when the admin's set the BanScopeOrgCreate bit, even
|
||||
// if the user can otherwise log in.
|
||||
@@ -398,9 +405,16 @@ func (s *organizationService) Update(ctx context.Context, orgID uuid.UUID, req *
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
org.Name = *req.Name
|
||||
name, nerr := displayname.Validate("Workspace name", *req.Name, displayname.Workspace, false)
|
||||
if nerr != nil {
|
||||
return nil, nerr
|
||||
}
|
||||
org.Name = name
|
||||
}
|
||||
if req.Slug != nil {
|
||||
if !slugPattern.MatchString(*req.Slug) {
|
||||
return nil, errx.NewWithIdentifier(errx.BadRequest, "invalid_slug", "Slug must be 2 to 80 lowercase letters, numbers or dashes, starting and ending with a letter or number.")
|
||||
}
|
||||
// Validate slug uniqueness
|
||||
existing, _ := s.orgRepo.GetBySlug(ctx, *req.Slug)
|
||||
if existing != nil && existing.ID != orgID {
|
||||
@@ -1246,6 +1260,8 @@ func (s *organizationService) CreateEnterpriseInquiry(ctx context.Context, inqui
|
||||
|
||||
// Helper functions
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,78}[a-z0-9]$`)
|
||||
|
||||
func generateSlug(name string) string {
|
||||
// Simple slug generation - lowercase, replace spaces with dashes
|
||||
slug := strings.ToLower(name)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/warmbly/warmbly/internal/app/cipher"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
@@ -529,6 +530,7 @@ func (s *service) mergeOrganization(ctx context.Context, tx pgx.Tx, orgID uuid.U
|
||||
if len(m.Organization) == 0 {
|
||||
return nil
|
||||
}
|
||||
cleanArchiveOrgName(m.Organization)
|
||||
destCols, err := s.repo.TableColumns(ctx, "organizations")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -575,6 +577,21 @@ func (s *service) mergeOrganization(ctx context.Context, tx pgx.Tx, orgID uuid.U
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanArchiveOrgName keeps the destination's name unless the archive's passes
|
||||
// the same rules as a rename.
|
||||
func cleanArchiveOrgName(org map[string]any) {
|
||||
raw, ok := org["name"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name, _ := raw.(string)
|
||||
if clean := displayname.Clean(name, displayname.Workspace); clean != "" {
|
||||
org["name"] = clean
|
||||
return
|
||||
}
|
||||
delete(org, "name")
|
||||
}
|
||||
|
||||
// orgMergeExcluded are the organization columns an archive may never set.
|
||||
var orgMergeExcluded = func() map[string]bool {
|
||||
out := map[string]bool{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.users DROP COLUMN IF EXISTS password_changed_at;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Every password write stamps this, and a reset link issued before it is
|
||||
-- refused. NULL is "never changed since this column existed", so links that
|
||||
-- are outstanding at deploy time keep working.
|
||||
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS password_changed_at timestamptz;
|
||||
@@ -153,7 +153,7 @@ const (
|
||||
|
||||
// CreateOrganizationRequest represents the request to create a new organization
|
||||
type CreateOrganizationRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateOrganizationRequest represents the request to update an organization
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package displayname is the one set of rules for a name a person chooses for
|
||||
// themselves or their workspace. Those names are shown to other people,
|
||||
// including in platform email, where a client turns anything that looks like
|
||||
// an address into a live link under Warmbly's sender. So a name carries text
|
||||
// and nothing a mail client, browser or terminal would act on.
|
||||
package displayname
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Kind selects the length bound and the minimum content of a name.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
// Person is a first or last name.
|
||||
Person Kind = iota
|
||||
// Workspace is an organization name.
|
||||
Workspace
|
||||
)
|
||||
|
||||
const (
|
||||
PersonMaxLength = 50
|
||||
WorkspaceMaxLength = 64
|
||||
maxCombiningRun = 3
|
||||
)
|
||||
|
||||
// Rejection says why a name was refused.
|
||||
type Rejection int
|
||||
|
||||
const (
|
||||
OK Rejection = iota
|
||||
Empty
|
||||
TooLong
|
||||
Link
|
||||
Characters
|
||||
NoLetter
|
||||
)
|
||||
|
||||
// ErrorCode is the response `code` for every refused name.
|
||||
const ErrorCode = "invalid_name"
|
||||
|
||||
var (
|
||||
schemeRe = regexp.MustCompile(`(?i)(?:^|[^\p{L}\p{N}])(?:https?|ftps?|mailto|tel|sms|callto|skype|javascript|data|file|news|irc|xmpp|ssh|git|ws|wss):\S`)
|
||||
// A label, a dot and a letter run: what linkifiers read as a hostname.
|
||||
domainRe = regexp.MustCompile(`[\p{L}\p{N}-]\.(?:[\p{L}]{2,}|xn--)`)
|
||||
ipv4Re = regexp.MustCompile(`\d{1,3}(?:\.\d{1,3}){3}`)
|
||||
)
|
||||
|
||||
// Normalize trims the name and collapses every run of spaces to one ASCII
|
||||
// space. It is the stored form of a name that passes Check.
|
||||
func Normalize(s string) string {
|
||||
s = norm.NFC.String(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
space := false
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Zs, r) || r == ' ' {
|
||||
space = true
|
||||
continue
|
||||
}
|
||||
if space && b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
space = false
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Check normalizes s and applies every rule for kind.
|
||||
func Check(s string, kind Kind) (string, Rejection) {
|
||||
s = Normalize(s)
|
||||
if s == "" {
|
||||
return "", Empty
|
||||
}
|
||||
if utf8.RuneCountInString(s) > maxLength(kind) {
|
||||
return s, TooLong
|
||||
}
|
||||
if r := content(s); r != OK {
|
||||
return s, r
|
||||
}
|
||||
if !hasLetter(s, kind) {
|
||||
return s, NoLetter
|
||||
}
|
||||
return s, OK
|
||||
}
|
||||
|
||||
// Clean returns the normalized name when it passes Check, otherwise "". Use it
|
||||
// for names that arrive from somewhere nobody can be asked to correct, such as
|
||||
// an identity provider or an email address.
|
||||
func Clean(s string, kind Kind) string {
|
||||
if out, r := Check(s, kind); r == OK {
|
||||
return out
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Displayable returns the normalized name when it is safe to show another
|
||||
// person, otherwise "". It skips the length bound so a stored name that
|
||||
// predates it still renders; the caller supplies the fallback.
|
||||
func Displayable(s string) string {
|
||||
s = Normalize(s)
|
||||
if s == "" || content(s) != OK {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// FromEmail derives a first name from an address's local part, with the
|
||||
// separators people use between names turned into spaces.
|
||||
func FromEmail(address string) string {
|
||||
local, _, ok := strings.Cut(address, "@")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
local, _, _ = strings.Cut(local, "+")
|
||||
local = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(local)
|
||||
if n := []rune(Normalize(local)); len(n) > PersonMaxLength {
|
||||
local = string(n[:PersonMaxLength])
|
||||
}
|
||||
return Clean(local, Person)
|
||||
}
|
||||
|
||||
// Validate is Check answered as an API error naming the field. An empty value
|
||||
// passes when optional is true.
|
||||
func Validate(label, s string, kind Kind, optional bool) (string, *errx.Error) {
|
||||
out, r := Check(s, kind)
|
||||
if r == Empty && optional {
|
||||
return "", nil
|
||||
}
|
||||
if r == OK {
|
||||
return out, nil
|
||||
}
|
||||
return "", Error(label, kind, r)
|
||||
}
|
||||
|
||||
// Error is the API error for a refused name.
|
||||
func Error(label string, kind Kind, r Rejection) *errx.Error {
|
||||
var msg string
|
||||
switch r {
|
||||
case Empty:
|
||||
msg = label + " is required."
|
||||
case TooLong:
|
||||
msg = fmt.Sprintf("%s must be %d characters or less.", label, maxLength(kind))
|
||||
case Link:
|
||||
msg = label + " cannot contain a link, web address or email address."
|
||||
case NoLetter:
|
||||
if kind == Workspace {
|
||||
msg = label + " must contain a letter or a number."
|
||||
} else {
|
||||
msg = label + " must contain a letter."
|
||||
}
|
||||
default:
|
||||
msg = label + " contains characters that are not allowed."
|
||||
}
|
||||
return errx.NewWithIdentifier(errx.BadRequest, ErrorCode, msg)
|
||||
}
|
||||
|
||||
func maxLength(kind Kind) int {
|
||||
if kind == Workspace {
|
||||
return WorkspaceMaxLength
|
||||
}
|
||||
return PersonMaxLength
|
||||
}
|
||||
|
||||
func content(s string) Rejection {
|
||||
combining := 0
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == utf8.RuneError,
|
||||
unicode.IsControl(r),
|
||||
unicode.In(r, unicode.Cf, unicode.Co, unicode.Cs, unicode.Zl, unicode.Zp),
|
||||
strings.ContainsRune("<>`\\", r):
|
||||
return Characters
|
||||
case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r):
|
||||
combining++
|
||||
if combining > maxCombiningRun {
|
||||
return Characters
|
||||
}
|
||||
default:
|
||||
combining = 0
|
||||
}
|
||||
}
|
||||
if looksLikeLink(s) {
|
||||
return Link
|
||||
}
|
||||
return OK
|
||||
}
|
||||
|
||||
// looksLikeLink reads the name the way a linkifier would, after folding the
|
||||
// full-width and ideographic forms that resolve to the same address.
|
||||
func looksLikeLink(s string) bool {
|
||||
f := strings.ToLower(norm.NFKC.String(s))
|
||||
f = strings.NewReplacer("\u3002", ".", "\uff61", ".").Replace(f)
|
||||
if strings.Contains(f, "@") || strings.Contains(f, "//") || strings.Contains(f, "www.") {
|
||||
return true
|
||||
}
|
||||
return schemeRe.MatchString(f) || domainRe.MatchString(f) || ipv4Re.MatchString(f)
|
||||
}
|
||||
|
||||
func hasLetter(s string, kind Kind) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || (kind == Workspace && unicode.IsDigit(r)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultWorkspace names the workspace created for a new account.
|
||||
func DefaultWorkspace(firstName string) string {
|
||||
if Normalize(firstName) == "" {
|
||||
return "My Organization"
|
||||
}
|
||||
if name := Clean(firstName+"'s Organization", Workspace); name != "" {
|
||||
return name
|
||||
}
|
||||
return "My Organization"
|
||||
}
|
||||
|
||||
// FullName joins the displayable parts of a person's name, "" when neither is.
|
||||
func FullName(first, last string) string {
|
||||
return strings.TrimSpace(Displayable(first) + " " + Displayable(last))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package displayname
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheck(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
kind Kind
|
||||
want Rejection
|
||||
}{
|
||||
{"Ada", Person, OK},
|
||||
{" Mary Ann ", Person, OK},
|
||||
{"O'Brien-Smith", Person, OK},
|
||||
{"J.R.R. Tolkien", Person, OK},
|
||||
{"St. John", Person, OK},
|
||||
{"Zo\u00eb", Person, OK},
|
||||
{"\u674e\u5c0f\u9f99", Person, OK},
|
||||
{"Acme 2.0", Workspace, OK},
|
||||
{"3M", Workspace, OK},
|
||||
{"", Person, Empty},
|
||||
{" ", Person, Empty},
|
||||
{"https://www.google.com", Person, Link},
|
||||
{"www.example", Person, Link},
|
||||
{"google.com", Person, Link},
|
||||
{"Visit evil.co now", Workspace, Link},
|
||||
{"\uff47\uff4f\uff4f\uff47\uff4c\uff45\uff0e\uff43\uff4f\uff4d", Person, Link},
|
||||
{"google\u3002com", Person, Link},
|
||||
{"a@b", Person, Link},
|
||||
{"javascript:alert(1)", Person, Link},
|
||||
{"mailto:x", Person, Link},
|
||||
{"Big Data: EU", Workspace, OK},
|
||||
{"//evil", Person, Link},
|
||||
{"10.0.0.1", Workspace, Link},
|
||||
{"xn--80ak6aa92e.xn--p1ai", Workspace, Link},
|
||||
{"Ada\u202eevil", Person, Characters},
|
||||
{"Ada\u200b", Person, Characters},
|
||||
{"Ada\nLovelace", Person, Characters},
|
||||
{"<b>Ada</b>", Person, Characters},
|
||||
{"Z\u0301\u0301\u0301\u0301\u0301", Person, Characters},
|
||||
{"1234", Person, NoLetter},
|
||||
{"!!!", Workspace, NoLetter},
|
||||
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Person, TooLong},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, got := Check(c.in, c.kind); got != c.want {
|
||||
t.Errorf("Check(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEmail(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"john.smith@example.com": "john smith",
|
||||
"ada+tag@example.com": "ada",
|
||||
"google.com@example.com": "google com",
|
||||
"www.evil.com@x.io": "www evil com",
|
||||
"12345@example.com": "",
|
||||
"no-at-sign": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := FromEmail(in); got != want {
|
||||
t.Errorf("FromEmail(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayable(t *testing.T) {
|
||||
if got := Displayable("https://evil.example"); got != "" {
|
||||
t.Errorf("Displayable kept a link: %q", got)
|
||||
}
|
||||
long := "A very long workspace name that predates the sixty four character bound"
|
||||
if got := Displayable(long); got != long {
|
||||
t.Errorf("Displayable dropped a long legacy name: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWorkspace(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "My Organization",
|
||||
" ": "My Organization",
|
||||
"Ada": "Ada's Organization",
|
||||
"evil.com": "My Organization",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := DefaultWorkspace(in); got != want {
|
||||
t.Errorf("DefaultWorkspace(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ type AuthRepository interface {
|
||||
ExternalLogin(ctx context.Context, email string) (*models.User, *errx.Error)
|
||||
ResetPassword(ctx context.Context, userID uuid.UUID, password string) *errx.Error
|
||||
GetPasswordHash(ctx context.Context, userID uuid.UUID) (string, *errx.Error)
|
||||
// PasswordChangedAt is when the password was last written, nil when it
|
||||
// has not been since the column existed.
|
||||
PasswordChangedAt(ctx context.Context, userID uuid.UUID) (*time.Time, *errx.Error)
|
||||
}
|
||||
|
||||
type authRepository struct {
|
||||
@@ -142,10 +145,28 @@ func (r *authRepository) GetPasswordHash(ctx context.Context, userID uuid.UUID)
|
||||
return *hash, nil
|
||||
}
|
||||
|
||||
// PasswordChangedAt returns the last password write, which is the floor a
|
||||
// reset link's issue time must clear.
|
||||
func (r *authRepository) PasswordChangedAt(ctx context.Context, userID uuid.UUID) (*time.Time, *errx.Error) {
|
||||
var at *time.Time
|
||||
err := r.DB.QueryRow(ctx, `SELECT password_changed_at FROM users WHERE id = $1`, userID).Scan(&at)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errx.ErrNotFound
|
||||
}
|
||||
db.CaptureError(err, "get password changed at", []any{userID}, "queryrow")
|
||||
return nil, errx.InternalError()
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
|
||||
// ResetPassword is the one write of a password hash, so it is also the one
|
||||
// place the change is stamped: every reset link issued before this instant is
|
||||
// refused from here on, whichever path (reset, change, operator CLI) wrote it.
|
||||
func (r *authRepository) ResetPassword(ctx context.Context, userID uuid.UUID, passwordHash string) *errx.Error {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET password_hash = $1, updated_at = now()
|
||||
SET password_hash = $1, password_changed_at = now(), updated_at = now()
|
||||
WHERE id = $2
|
||||
`
|
||||
params := []any{
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/kms"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/pkg/displayname"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
@@ -93,14 +94,8 @@ func (r *userRepository) CreateUser(ctx context.Context, email *mail.Address, pa
|
||||
// case someone typed is a row no sign-in and no reset can find.
|
||||
address := normalizeUserEmail(email.Address)
|
||||
|
||||
var firstName string
|
||||
|
||||
nameSplit := strings.SplitN(address, "@", 2)
|
||||
if len(nameSplit) < 2 {
|
||||
firstName = "Unknown"
|
||||
} else {
|
||||
firstName = nameSplit[0]
|
||||
}
|
||||
// Only a local part that passes the display-name rules becomes a name.
|
||||
firstName := displayname.FromEmail(address)
|
||||
|
||||
var lastName string
|
||||
now := time.Now()
|
||||
@@ -369,10 +364,7 @@ func (r *userRepository) CreateExemptUser(ctx context.Context, email *mail.Addre
|
||||
|
||||
id := uuid.New()
|
||||
address := normalizeUserEmail(email.Address)
|
||||
firstName := "Unknown"
|
||||
if parts := strings.SplitN(address, "@", 2); len(parts) == 2 {
|
||||
firstName = parts[0]
|
||||
}
|
||||
firstName := displayname.FromEmail(address)
|
||||
now := time.Now()
|
||||
if _, ierr := tx.Exec(ctx, `
|
||||
INSERT INTO users (id, email, password_hash, first_name, last_name, created_at, updated_at)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useUserProfile } from "@/hooks/context/user";
|
||||
import { NumberInput, TextInput } from "@/components/ui/field";
|
||||
import { FieldError, NumberInput, TextInput } from "@/components/ui/field";
|
||||
import useUpdateProfile from "@/lib/api/hooks/auth/useUpdateProfile";
|
||||
import useUpdateSendPreferences from "@/lib/api/hooks/auth/useUpdateSendPreferences";
|
||||
import { AvatarUploader } from "@/components/app/avatar/AvatarUploader";
|
||||
@@ -12,6 +12,7 @@ import { Row, Section, SectionShell, initials } from "../_components/SectionShel
|
||||
import SaveStatus from "../_components/SaveStatus";
|
||||
import { useAutosave, type AutosaveStatus } from "@/hooks/useAutosave";
|
||||
import { useRegisterUnsaved } from "@/hooks/context/unsaved";
|
||||
import { PERSON_NAME_MAX, nameError, normalizeName } from "@/lib/displayName";
|
||||
|
||||
// Header indicator priority when two autosaves share one SaveStatus.
|
||||
function combineStatus(a: AutosaveStatus, b: AutosaveStatus): AutosaveStatus {
|
||||
@@ -30,18 +31,21 @@ export default function ProfileSettingsPage() {
|
||||
const removeAvatar = useDeleteUserAvatar();
|
||||
const updateProfile = useUpdateProfile();
|
||||
|
||||
// Auto-save names ~700ms after the user stops typing. Empty names are not
|
||||
// persisted (the server requires both), so the field just stays unsaved.
|
||||
// Auto-save names ~700ms after the user stops typing. A name the server
|
||||
// would refuse is not sent; the field shows why and stays unsaved.
|
||||
// Memoized so the debounce timer only resets on an actual name change.
|
||||
const value = React.useMemo(
|
||||
() => ({ firstName: firstName.trim(), lastName: lastName.trim() }),
|
||||
() => ({ firstName: normalizeName(firstName), lastName: normalizeName(lastName) }),
|
||||
[firstName, lastName],
|
||||
);
|
||||
const firstError = nameError("First name", firstName, "person");
|
||||
const lastError = nameError("Last name", lastName, "person");
|
||||
const autosave = useAutosave({
|
||||
value,
|
||||
debounceMs: 700,
|
||||
save: async (v) => {
|
||||
if (!v.firstName || !v.lastName) throw new Error("name required");
|
||||
const invalid = nameError("First name", v.firstName, "person") ?? nameError("Last name", v.lastName, "person");
|
||||
if (invalid) throw new Error(invalid);
|
||||
await updateProfile.mutateAsync({ first_name: v.firstName, last_name: v.lastName });
|
||||
},
|
||||
});
|
||||
@@ -87,11 +91,29 @@ export default function ProfileSettingsPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FieldLabel>First name</FieldLabel>
|
||||
<TextInput value={firstName} onChange={setFirstName} className="w-full" />
|
||||
<TextInput
|
||||
value={firstName}
|
||||
onChange={setFirstName}
|
||||
invalid={!!firstError}
|
||||
title={firstError ?? undefined}
|
||||
maxLength={PERSON_NAME_MAX}
|
||||
autoComplete="given-name"
|
||||
className="w-full"
|
||||
/>
|
||||
<FieldError message={firstError} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Last name</FieldLabel>
|
||||
<TextInput value={lastName} onChange={setLastName} className="w-full" />
|
||||
<TextInput
|
||||
value={lastName}
|
||||
onChange={setLastName}
|
||||
invalid={!!lastError}
|
||||
title={lastError ?? undefined}
|
||||
maxLength={PERSON_NAME_MAX}
|
||||
autoComplete="family-name"
|
||||
className="w-full"
|
||||
/>
|
||||
<FieldError message={lastError} />
|
||||
</div>
|
||||
</div>
|
||||
<Row
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAppStore, type Organization as StoreOrganization } from "@/stores";
|
||||
import { TextInput } from "@/components/ui/field";
|
||||
import { FieldError, TextInput } from "@/components/ui/field";
|
||||
import { WORKSPACE_NAME_MAX, nameError, normalizeName } from "@/lib/displayName";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import useUpdateOrganization from "@/lib/api/hooks/app/organizations/useUpdateOrganization";
|
||||
import type Organization from "@/lib/api/models/app/organizations/Organization";
|
||||
@@ -113,13 +114,15 @@ function WorkspaceSettings({ org: currentOrg }: { org: StoreOrganization | null
|
||||
void saveToThisWorkspace({ assistant_shared_history: next });
|
||||
};
|
||||
|
||||
// Auto-save the workspace name ~700ms after typing stops. An empty name is
|
||||
// never persisted; the field just stays unsaved until it's valid again.
|
||||
// Auto-save the workspace name ~700ms after typing stops. A name the server
|
||||
// would refuse is never sent; the field shows why and stays unsaved.
|
||||
const nameInvalid = nameError("Workspace name", name, "workspace");
|
||||
const autosave = useAutosave({
|
||||
value: name.trim(),
|
||||
value: normalizeName(name),
|
||||
debounceMs: 700,
|
||||
save: async (v) => {
|
||||
if (!v) throw new Error("name required");
|
||||
const invalid = nameError("Workspace name", v, "workspace");
|
||||
if (invalid) throw new Error(invalid);
|
||||
await saveToThisWorkspace({ name: v });
|
||||
},
|
||||
});
|
||||
@@ -158,7 +161,17 @@ function WorkspaceSettings({ org: currentOrg }: { org: StoreOrganization | null
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Workspace name" description="Shown in the sidebar and invitation emails.">
|
||||
<TextInput value={name} onChange={setName} className="w-full max-w-[280px]" />
|
||||
<div className="w-full max-w-[280px]">
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={setName}
|
||||
invalid={!!nameInvalid}
|
||||
title={nameInvalid ?? undefined}
|
||||
maxLength={WORKSPACE_NAME_MAX}
|
||||
className="w-full"
|
||||
/>
|
||||
<FieldError message={nameInvalid} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row
|
||||
label="Workspace ID"
|
||||
|
||||
@@ -16,13 +16,20 @@ import useUpdateOrganization from "@/lib/api/hooks/app/organizations/useUpdateOr
|
||||
import useCurrentOrganization from "@/lib/api/hooks/app/organizations/useCurrentOrganization";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { PERSON_NAME_MAX, WORKSPACE_NAME_MAX, nameError, normalizeName, type NameKind } from "@/lib/displayName";
|
||||
|
||||
/* ── Schema ─────────────────────── */
|
||||
|
||||
const displayName = (label: string, kind: NameKind) =>
|
||||
z.string().superRefine((value, ctx) => {
|
||||
const message = nameError(label, value, kind);
|
||||
if (message) ctx.addIssue({ code: "custom", message });
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
first_name: z.string().min(1, "First name is required").max(50, "50 characters max"),
|
||||
last_name: z.string().min(1, "Last name is required").max(50, "50 characters max"),
|
||||
workspace: z.string().min(1, "Workspace name is required").max(60, "60 characters max"),
|
||||
first_name: displayName("First name", "person"),
|
||||
last_name: displayName("Last name", "person"),
|
||||
workspace: displayName("Workspace name", "workspace"),
|
||||
role: z.enum(["founder", "sales", "marketing", "agency", "recruiter", "other"], {
|
||||
error: "Pick the closest one",
|
||||
}),
|
||||
@@ -186,16 +193,17 @@ export default function OnboardingPage() {
|
||||
try {
|
||||
// Rename the auto-created workspace if the user changed it. Best
|
||||
// effort: a rename hiccup must never block completing onboarding.
|
||||
if (org?.name && org.name !== data.workspace) {
|
||||
const workspace = normalizeName(data.workspace);
|
||||
if (org?.name && org.name !== workspace) {
|
||||
try {
|
||||
await updateOrganization.mutateAsync({ name: data.workspace });
|
||||
await updateOrganization.mutateAsync({ name: workspace });
|
||||
} catch {
|
||||
/* keep going — onboarding completion matters more */
|
||||
}
|
||||
}
|
||||
await completeOnboarding.mutateAsync({
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
first_name: normalizeName(data.first_name),
|
||||
last_name: normalizeName(data.last_name),
|
||||
referral_source: data.referral_source,
|
||||
role: data.role,
|
||||
team_size: data.team_size,
|
||||
@@ -273,12 +281,12 @@ export default function OnboardingPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<FieldLabel>First name</FieldLabel>
|
||||
<input type="text" placeholder="John" className={INPUT} maxLength={50} autoFocus {...register("first_name")} />
|
||||
<input type="text" placeholder="John" className={INPUT} maxLength={PERSON_NAME_MAX} autoComplete="given-name" autoFocus {...register("first_name")} />
|
||||
<FieldError message={errors.first_name?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Last name</FieldLabel>
|
||||
<input type="text" placeholder="Doe" className={INPUT} maxLength={50} {...register("last_name")} />
|
||||
<input type="text" placeholder="Doe" className={INPUT} maxLength={PERSON_NAME_MAX} autoComplete="family-name" {...register("last_name")} />
|
||||
<FieldError message={errors.last_name?.message} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,7 +295,7 @@ export default function OnboardingPage() {
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<FieldLabel>Workspace name</FieldLabel>
|
||||
<input type="text" placeholder="Acme Inc." className={INPUT} maxLength={60} autoFocus {...register("workspace")} />
|
||||
<input type="text" placeholder="Acme Inc." className={INPUT} maxLength={WORKSPACE_NAME_MAX} autoFocus {...register("workspace")} />
|
||||
<FieldError message={errors.workspace?.message} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,8 @@ import getUser from "@/lib/api/client/auth/getUser";
|
||||
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
|
||||
import { usePasswordStrength } from "@/hooks/usePasswordStrength";
|
||||
import { saveTokens } from "@/lib/auth";
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import { FieldError, Label, TextInput } from "@/components/ui/field";
|
||||
import { PERSON_NAME_MAX, nameError, normalizeName } from "@/lib/displayName";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import type AuthConfig from "@/lib/api/models/auth/AuthConfig";
|
||||
|
||||
@@ -81,6 +82,11 @@ export default function SetupPage() {
|
||||
toast.error("Enter the email address for the owner account.");
|
||||
return;
|
||||
}
|
||||
const invalidName = nameError("Name", firstName, "person", true);
|
||||
if (invalidName) {
|
||||
toast.error(invalidName);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long.");
|
||||
return;
|
||||
@@ -102,7 +108,7 @@ export default function SetupPage() {
|
||||
token,
|
||||
email,
|
||||
password,
|
||||
first_name: firstName || undefined,
|
||||
first_name: normalizeName(firstName) || undefined,
|
||||
});
|
||||
saveTokens(session as unknown as Record<string, unknown>);
|
||||
// Prime the profile with the new token before entering the gated
|
||||
@@ -198,8 +204,11 @@ export default function SetupPage() {
|
||||
onChange={setFirstName}
|
||||
autoComplete="given-name"
|
||||
placeholder="Alex"
|
||||
invalid={!!nameError("Name", firstName, "person", true)}
|
||||
maxLength={PERSON_NAME_MAX}
|
||||
className={FIELD}
|
||||
/>
|
||||
<FieldError message={nameError("Name", firstName, "person", true)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -12,7 +12,10 @@ import { useAppStore } from "@/stores";
|
||||
import useEnterpriseInquiry from "@/lib/api/hooks/app/subscription/useEnterpriseInquiry";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import { Label, NumberInput, TextInput } from "@/components/ui/field";
|
||||
import { FieldError, Label, NumberInput, TextInput } from "@/components/ui/field";
|
||||
import { PERSON_NAME_MAX, WORKSPACE_NAME_MAX, nameError, normalizeName } from "@/lib/displayName";
|
||||
|
||||
const NOTES_MAX = 2000;
|
||||
|
||||
export default function EnterpriseInquiryDialog({
|
||||
open,
|
||||
@@ -85,14 +88,16 @@ export default function EnterpriseInquiryDialog({
|
||||
};
|
||||
}, [open, inquiry.isPending, onClose]);
|
||||
|
||||
const valid = company.trim() && name.trim() && /.+@.+\..+/.test(email.trim());
|
||||
const companyError = company.trim() ? nameError("Company name", company, "workspace") : null;
|
||||
const nameInvalid = name.trim() ? nameError("Contact name", name, "person") : null;
|
||||
const valid = company.trim() && name.trim() && !companyError && !nameInvalid && /.+@.+\..+/.test(email.trim());
|
||||
|
||||
async function submit() {
|
||||
if (!valid || inquiry.isPending) return;
|
||||
try {
|
||||
const res = await inquiry.mutateAsync({
|
||||
company_name: company.trim(),
|
||||
contact_name: name.trim(),
|
||||
company_name: normalizeName(company),
|
||||
contact_name: normalizeName(name),
|
||||
contact_email: email.trim(),
|
||||
estimated_volume: Number.isFinite(volume) ? volume : undefined,
|
||||
team_size: Number.isFinite(teamSize) ? teamSize : undefined,
|
||||
@@ -159,12 +164,26 @@ export default function EnterpriseInquiryDialog({
|
||||
</p>
|
||||
<div>
|
||||
<Label>Company</Label>
|
||||
<TextInput value={company} onChange={setCompany} placeholder="Acme Inc" />
|
||||
<TextInput
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
placeholder="Acme Inc"
|
||||
invalid={!!companyError}
|
||||
maxLength={WORKSPACE_NAME_MAX}
|
||||
/>
|
||||
<FieldError message={companyError} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Your name</Label>
|
||||
<TextInput value={name} onChange={setName} placeholder="Jane Doe" />
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="Jane Doe"
|
||||
invalid={!!nameInvalid}
|
||||
maxLength={PERSON_NAME_MAX}
|
||||
/>
|
||||
<FieldError message={nameInvalid} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Work email</Label>
|
||||
@@ -191,6 +210,7 @@ export default function EnterpriseInquiryDialog({
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
maxLength={NOTES_MAX}
|
||||
rows={3}
|
||||
placeholder="Mailbox count, providers, compliance needs…"
|
||||
className="w-full rounded-md border border-slate-200 px-2.5 py-1.5 text-[12.5px] text-slate-900 placeholder:text-slate-400 focus:outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 resize-y"
|
||||
|
||||
@@ -11,7 +11,8 @@ import { BriefcaseIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import useCreateOrganization from "@/lib/api/hooks/app/organizations/useCreateOrganization";
|
||||
import useSwitchOrganization from "@/lib/api/hooks/app/organizations/useSwitchOrganization";
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import { FieldError, Label, TextInput } from "@/components/ui/field";
|
||||
import { WORKSPACE_NAME_MAX, nameError, normalizeName } from "@/lib/displayName";
|
||||
import { useAppStore } from "@/stores";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
@@ -31,10 +32,12 @@ export function NewWorkspaceDialog({ open, onClose }: Props) {
|
||||
if (!open) setName("");
|
||||
}, [open]);
|
||||
|
||||
const error = nameError("Workspace name", name, "workspace");
|
||||
|
||||
async function submit() {
|
||||
const t = name.trim();
|
||||
if (t.length < 2) {
|
||||
toast.error("Name is required");
|
||||
const t = normalizeName(name);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -108,8 +111,11 @@ export function NewWorkspaceDialog({ open, onClose }: Props) {
|
||||
onChange={setName}
|
||||
placeholder="Acme outbound"
|
||||
autoFocus
|
||||
invalid={!!name.trim() && !!error}
|
||||
maxLength={WORKSPACE_NAME_MAX}
|
||||
className="w-full"
|
||||
/>
|
||||
<FieldError message={name.trim() ? error : null} />
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed pt-1">
|
||||
You'll be the owner. Rename and invite teammates later.
|
||||
@@ -127,7 +133,7 @@ export function NewWorkspaceDialog({ open, onClose }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={create.isPending || name.trim().length < 2}
|
||||
disabled={create.isPending || !!error}
|
||||
className="h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{create.isPending && <Loader2Icon className="w-3 h-3 animate-spin" />}
|
||||
|
||||
@@ -139,6 +139,16 @@ export function Label({ children, className }: { children: React.ReactNode; clas
|
||||
);
|
||||
}
|
||||
|
||||
// The line under a field saying why its value is refused; renders nothing when valid.
|
||||
export function FieldError({ message, className }: { message?: string | null; className?: string }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p role="alert" className={cn("mt-1 text-[11.5px] text-red-600", className)}>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldRow({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nameError } from "./displayName";
|
||||
|
||||
describe("nameError", () => {
|
||||
it.each(["Ada", " Mary Ann ", "O'Brien-Smith", "J.R.R. Tolkien", "St. John", "Zoë", "李小龙"])(
|
||||
"accepts %s",
|
||||
(v) => expect(nameError("Name", v, "person")).toBeNull(),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"https://www.google.com",
|
||||
"google.com",
|
||||
"www.example",
|
||||
"a@b",
|
||||
"javascript:alert(1)",
|
||||
"mailto:x",
|
||||
"google.com",
|
||||
"google。com",
|
||||
"10.0.0.1",
|
||||
])("refuses the link %s", (v) => expect(nameError("Name", v, "person")).toMatch(/cannot contain a link/));
|
||||
|
||||
it.each(["Adaevil", "Ada", "<b>Ada</b>", "Ź́́́́"])(
|
||||
"refuses the characters in %s",
|
||||
(v) => expect(nameError("Name", v, "person")).toMatch(/not allowed/),
|
||||
);
|
||||
|
||||
it("bounds length by kind", () => {
|
||||
expect(nameError("Name", "a".repeat(51), "person")).toMatch(/50/);
|
||||
expect(nameError("Name", "a".repeat(64), "workspace")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows an empty optional name", () => {
|
||||
expect(nameError("Name", " ", "person", true)).toBeNull();
|
||||
expect(nameError("Name", " ", "person")).toMatch(/required/);
|
||||
});
|
||||
|
||||
it("lets a workspace be only digits", () => {
|
||||
expect(nameError("Workspace name", "3000", "workspace")).toBeNull();
|
||||
expect(nameError("Name", "3000", "person")).toMatch(/letter/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Mirrors internal/pkg/displayname: names reach other people's inboxes, where
|
||||
// anything shaped like an address is turned into a link. The server is the
|
||||
// authority; this only lets the form say so before the request.
|
||||
|
||||
export type NameKind = "person" | "workspace";
|
||||
|
||||
export const PERSON_NAME_MAX = 50;
|
||||
export const WORKSPACE_NAME_MAX = 64;
|
||||
const MAX_COMBINING_RUN = 3;
|
||||
|
||||
const SCHEME =
|
||||
/(?:^|[^\p{L}\p{N}])(?:https?|ftps?|mailto|tel|sms|callto|skype|javascript|data|file|news|irc|xmpp|ssh|git|ws|wss):\S/iu;
|
||||
const DOMAIN = /[\p{L}\p{N}-]\.(?:\p{L}{2,}|xn--)/u;
|
||||
const IPV4 = /\d{1,3}(?:\.\d{1,3}){3}/;
|
||||
const FORBIDDEN_CHAR = /[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Zl}\p{Zp}<>`\\]/u;
|
||||
const COMBINING = /[\p{Mn}\p{Me}]/u;
|
||||
|
||||
/** Trims and collapses spaces, the form the server stores. */
|
||||
export function normalizeName(value: string): string {
|
||||
return value.normalize("NFC").trim().replace(/[\p{Zs} ]+/gu, " ");
|
||||
}
|
||||
|
||||
function looksLikeLink(value: string): boolean {
|
||||
const f = value.normalize("NFKC").toLowerCase().replace(/[。。]/g, ".");
|
||||
if (f.includes("@") || f.includes("//") || f.includes("www.")) return true;
|
||||
return SCHEME.test(f) || DOMAIN.test(f) || IPV4.test(f);
|
||||
}
|
||||
|
||||
/** The reason a name would be refused, or null when it is accepted. */
|
||||
export function nameError(label: string, value: string, kind: NameKind, optional = false): string | null {
|
||||
const name = normalizeName(value);
|
||||
if (!name) return optional ? null : `${label} is required.`;
|
||||
const max = kind === "workspace" ? WORKSPACE_NAME_MAX : PERSON_NAME_MAX;
|
||||
if ([...name].length > max) return `${label} must be ${max} characters or less.`;
|
||||
let combining = 0;
|
||||
for (const ch of name) {
|
||||
if (FORBIDDEN_CHAR.test(ch)) return `${label} contains characters that are not allowed.`;
|
||||
combining = COMBINING.test(ch) ? combining + 1 : 0;
|
||||
if (combining > MAX_COMBINING_RUN) return `${label} contains characters that are not allowed.`;
|
||||
}
|
||||
if (looksLikeLink(name)) return `${label} cannot contain a link, web address or email address.`;
|
||||
const letter = kind === "workspace" ? /[\p{L}\p{N}]/u : /\p{L}/u;
|
||||
if (!letter.test(name)) {
|
||||
return kind === "workspace" ? `${label} must contain a letter or a number.` : `${label} must contain a letter.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user