feat: cap workspace invitations and reviewer tester passwords at 48 hours, quote each invitation's real remaining lifetime in its email, list recently expired invitations behind include_expired on GET /organization/invitations, and add a one-click Resend for expired invitations on the members settings page

This commit is contained in:
Matthew Meszaros
2026-10-23 15:57:22 +02:00
parent 31aadd0304
commit 0d76f81a3f
23 changed files with 252 additions and 69 deletions
+6 -6
View File
@@ -50,7 +50,7 @@ export default function TestersPage() {
const [orgQuery, setOrgQuery] = useState("");
const [org, setOrg] = useState<AdminOrgListItem | null>(null);
const [roleID, setRoleID] = useState("");
const [passwordDays, setPasswordDays] = useState(30);
const [passwordHours, setPasswordHours] = useState(48);
// Held in state, never refetched: the server returns it once and cannot
// produce it again.
const [created, setCreated] = useState<CreatedTester | null>(null);
@@ -83,7 +83,7 @@ export default function TestersPage() {
createTester({
email: email.trim(),
reason: reason.trim(),
password_days: passwordDays,
password_hours: passwordHours,
...(joining
? { organization_id: org!.id, role_id: roleID }
: { org_name: orgName.trim() || undefined }),
@@ -96,7 +96,7 @@ export default function TestersPage() {
setOrgQuery("");
setOrg(null);
setRoleID("");
setPasswordDays(30);
setPasswordHours(48);
qc.invalidateQueries({ queryKey: ["admin", "testers"] });
toast.success("Tester created");
},
@@ -450,14 +450,14 @@ export default function TestersPage() {
</Field>
<Field label="Password works for">
<Select value={String(passwordDays)} onValueChange={(v) => setPasswordDays(Number(v))}>
<Select value={String(passwordHours)} onValueChange={(v) => setPasswordHours(Number(v))}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[7, 14, 30, 60, 90].map((d) => (
{[4, 12, 24, 48].map((d) => (
<SelectItem key={d} value={String(d)}>
{d} days
{d} hours
</SelectItem>
))}
</SelectContent>
@@ -25,7 +25,7 @@ const SETTINGS_KEY = ["admin", "instance", "settings"];
// The backend clamps to the same band; matching it here keeps the operator
// from spending a round-trip to learn the range.
const TTL_MIN_HOURS = 1;
const TTL_MAX_HOURS = 720;
const TTL_MAX_HOURS = 48;
// Sending-domain authentication grace, mirroring internal/app/instancesettings.
const AUTH_GRACE_MIN_HOURS = 1;
@@ -561,7 +561,7 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
id="ttl-hours"
label="Invitation validity (hours)"
help="Existing invitations keep the expiry they were issued with."
range={`Between ${TTL_MIN_HOURS} and ${TTL_MAX_HOURS} hours (30 days).`}
range={`Between ${TTL_MIN_HOURS} and ${TTL_MAX_HOURS} hours (2 days).`}
value={form.ttlHours}
onChange={(v) => setForm({ ...form, ttlHours: v })}
valid={ttlValid}
+2 -2
View File
@@ -20,8 +20,8 @@ export function createTester(body: {
/** Joins an existing workspace instead of minting one. Requires role_id. */
organization_id?: string;
role_id?: string;
/** Days the password works, 1 to 90. Defaults to 30. */
password_days?: number;
/** Hours the password works, 1 to 48. Defaults to 48. */
password_hours?: number;
}): Promise<CreatedTester> {
return Request({ method: "POST", url: "/admin/testers", authorization: true, data: body });
}
+1 -1
View File
@@ -662,7 +662,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example
- `GET /me/views/:view`, `PUT /me/views/:view`, `DELETE /me/views/:view` (the signed-in member's own column layout and sort for a dashboard list in the current workspace; `view` is `contacts` or `campaign_leads`, or `unibox_rail` for the unibox scope rail, which saves a `layout` of `favorites`, `hidden`, `order` and `section_order` instead of columns. Personal to the session, so no API scope reaches it)
- `GET /invitations`, `POST /invitations/accept`
- All of `/organization/*` (create, switch, members, invitations, transfer ownership, avatar, danger zone)
- `GET /organization/members` includes each member's `access_scope` (`workspace` or `restricted`) and `access` (`scope`, `campaign_folder_ids`, `campaign_ids`, `email_account_ids`); `GET /organization` carries the caller's own `access_scope` per workspace. `GET /organization/invitations` carries each invitation's `access`
- `GET /organization/members` includes each member's `access_scope` (`workspace` or `restricted`) and `access` (`scope`, `campaign_folder_ids`, `campaign_ids`, `email_account_ids`); `GET /organization` carries the caller's own `access_scope` per workspace. `GET /organization/invitations` carries each invitation's `access`. It lists pending invitations; with `?include_expired=true` it also lists the 50 most recently expired, each marked `"expired": true`, so they can be sent again by inviting the same address. Any other value returns `400`
- `GET /organization/members/:id/access` and `PUT /organization/members/:id/access` (JWT permission `MANAGE_TEAM`; the `PUT` also needs a recent confirmation). `:id` is the member's user id. The body is the whole scope, `{"scope": "restricted", "campaign_folder_ids": [], "campaign_ids": [], "email_account_ids": []}`, and replaces what was there, so a retry lands on the same state and takes no `Idempotency-Key`. `scope: "workspace"` clears the grants. Every id must belong to the workspace (`400` `invalid_access_resource`), at most 500 per list, the owner cannot be restricted (`403` `owner_access_unrestricted`), nobody can change their own access, and the caller must hold every permission the member's roles grant. Answers the member with `access`, and publishes `AUDIT_CREATED` with `entity_type` `organization_member`
- `POST /organization/members/invite` accepts the same object as `access`, so an invitee joins already restricted. Ids are checked on invite and again on acceptance, which drops grants on anything deleted in between
- `GET /organization/access/suggested-senders?campaign_ids=&campaign_folder_ids=` (JWT permission `MANAGE_TEAM`): the mailboxes those campaigns, and the campaigns in those folders, send from (explicit senders and sending-tag matches), each with the `campaign_ids` it sends for. A suggestion grants nothing
@@ -67,7 +67,7 @@ So "I registered fine yesterday and today it says the server is invite only" is
|---|---|---|---|
| This server is invite only. Ask an administrator to invite you, then open the link in the invitation to create your account. | `registration_invite_only` | 403 | `DISABLE_REGISTRATION=invite_only`. Get an invitation and create the account from its link, or have an administrator run `warmblyctl user create` |
| This server is not accepting new accounts. | `registration_closed` | 403 | `DISABLE_REGISTRATION=true`. No invitation overrides this. Only `warmblyctl user create` adds accounts, or change the mode and restart |
| That invitation link is invalid, expired, or was issued for a different email address. Ask for a fresh one. | `invitation_invalid` | 403 | The invitation expired (7 days by default), was revoked, was already used, or the address you typed is not the address it was issued for |
| That invitation link is invalid, expired, or was issued for a different email address. Ask for a fresh one. | `invitation_invalid` | 403 | The invitation expired (48 hours by default), was revoked, was already used, or the address you typed is not the address it was issued for |
| That setup link is invalid, already used, or expired. | `setup_token_invalid` | 401 | 24 hour lifetime, single use, and it does not survive a Redis flush. Print a new one with `warmblyctl setup-link` |
| This instance has already been set up. | `setup_already_complete` | 403 | An account exists, so no claim link is issued. Sign in, or see [recovering access](#recovering-access) |
@@ -119,7 +119,7 @@ A reason is required, and so is `--by`: this command talks to Postgres directly
Turning the switch off ends the exemption and signs out every session the account holds, exactly like revoking it under **Testers**.
The admin panel has the same thing under **Testers**: create one, see the password once, and revoke an exemption when the reason no longer holds. The password a tester is created with works for a period you choose, 1 to 90 days (30 by default), and stops working on that date; the list shows when. Revoking a tester ends the exemption, clears that password and signs out every session the account holds. The account itself survives a revoke, so anything it did stays attributable, and so does any workspace membership it was given: remove the member from the workspace, or ban the account, to end its access entirely. An account exempted with `warmblyctl` has no handed-out password, so revoking it from the panel ends the exemption and its sessions and leaves its own password alone.
The admin panel has the same thing under **Testers**: create one, see the password once, and revoke an exemption when the reason no longer holds. The password a tester is created with works for a period you choose, 1 to 48 hours (48 by default), and stops working then; the list shows when. Revoking a tester ends the exemption, clears that password and signs out every session the account holds. The account itself survives a revoke, so anything it did stays attributable, and so does any workspace membership it was given: remove the member from the workspace, or ban the account, to end its access entirely. An account exempted with `warmblyctl` has no handed-out password, so revoking it from the panel ends the exemption and its sessions and leaves its own password alone.
#### Which workspace a tester lands in
@@ -233,8 +233,8 @@ On a phone or tablet the row actions are always visible; on a desktop they appea
Send the link over a channel you trust. It is a bearer credential: anyone holding it can create an account in your workspace with the roles you attached, until it expires or you cancel it.
<Callout type="info" title="There is no resend button">
Nothing re-sends an invitation email, because on a `log` transport there would be nothing to send it with. To give somebody a fresh link, invite the same address again: that replaces the row in place with a new token and a reset expiry, and the previously copied link stops working.
<Callout type="info" title="Expired invitations">
An invitation works for 48 hours by default. One that ran out stays on the list marked **Expired**, with **Resend** on its row. Resend invites the same address again: the row gets a new token and a reset expiry, and the previously copied link stops working. On a `log` transport the new email is not delivered either, so copy the new link from the row once it is pending again.
</Callout>
If you would rather read it out of the log instead:
@@ -249,12 +249,12 @@ The cleanest fix is still to point `MAIL_TRANSPORT` at a real relay. See [platfo
| Property | Behavior |
|---|---|
| Expiry | 7 days by default. Change it with `invitations.ttl_hours` under **Instance > Configuration > Settings**, clamped to 1 to 720 hours. It is read when the invitation is written, so a change applies to new invitations, not to outstanding ones |
| Expiry | 48 hours by default. Shorten it with `invitations.ttl_hours` under **Instance > Configuration > Settings**, clamped to 1 to 48 hours; a longer value saved by an earlier release is read as 48. It is read when the invitation is written, so a change applies to new invitations, not to outstanding ones |
| Address binding | The invitation is bound to the address it was issued for. Registering a different address with the same token is refused |
| Re-inviting the same address | Replaces the row in place: one row per address per workspace, a rotated token, a reset expiry, and the previously copied link dead |
| Copy link | Returns the token for a pending invitation so you can build and share the URL yourself |
| Cancel | Revokes it immediately |
| Expired rows | No longer shown in the dashboard and do not block new invitations. Re-inviting the address replaces the expired row. A platform admin with `manage_organizations` can remove expired records across all workspaces from **Instance > Setup and health > Findings**, with confirmation and an audit entry. Active invitations and members are unchanged |
| Expired rows | Grant nothing and do not block new invitations. The 50 most recently expired stay under **Pending invitations** marked **Expired**, with **Resend**, which invites the address again with the same roles and access. A platform admin with `manage_organizations` can remove expired records across all workspaces from **Instance > Setup and health > Findings**, with confirmation and an audit entry. Active invitations and members are unchanged |
| Turning links off | Set `invitations.links_enabled` to false under **Instance > Configuration > Settings** if invitations must arrive by mail and nowhere else. The copy action then fails with an explanation instead of returning a token. Only do this once mail actually delivers |
Invitations join an existing workspace and never create one. To stand up a **second organization** you need `DISABLE_REGISTRATION=false`, or an administrator running `warmblyctl user create`.
@@ -437,7 +437,7 @@ Understand what that means on an instance reachable from the internet: every vis
|---|---|
| They never received the invitation | Mail probably does not deliver. Click the copy icon on their row under **Pending invitations** and send the link yourself. See [without a mail relay](#without-a-mail-relay) |
| They opened the sign-up form and got a 403 | They need the invitation link, not the form. The link carries the token that permits the signup |
| Their invitation expired | Re-invite the same address. It replaces the row and mints a fresh link |
| Their invitation expired | Click **Resend** on its row under **Pending invitations**, or invite the same address again. Either replaces the row and mints a fresh link |
| Nobody can reset a password | `MAIL_TRANSPORT` does not deliver. Use `warmblyctl user reset-password` and hand over the printed URL |
| You lost the only admin account | `warmblyctl user create --email you@example.com --admin`, then sign in at the admin panel |
@@ -984,7 +984,7 @@ These are the only settings a browser can change, and no environment variable ow
| Setting | Type | Default | What it does |
|---|---|---|---|
| `invitations.ttl_hours` | integer, 1 to 720 | `168` | How long a new invitation stays valid. Read when the invitation row is written, so it applies to invitations created after the change, not to existing ones |
| `invitations.ttl_hours` | integer, 1 to 48 | `48` | How long a new invitation stays valid. Read when the invitation row is written, so it applies to invitations created after the change, not to existing ones |
| `invitations.links_enabled` | boolean | `true` | Whether the copyable invitation link is returned at all. Off makes `GET /organization/invitations/:id/link` return `404` with an explanation, so an invitation can only arrive by mail |
| `access.allow_invited_signup` | boolean | `true` | Whether holding a live invitation lets someone create their own account under `invite_only`. Off means an administrator creates every account with `warmblyctl user create` |
| `sync.backfill_days` | integer, 1 to 730 | `90` | How far back a newly connected mailbox's initial import reaches, newest first |
@@ -48,7 +48,7 @@ Newer builds return the invite-only refusal with its own machine code, `registra
| `make up` printed no claim link | The database already has accounts, usually from a previous `make dev`. Use `warmblyctl user create --email you@example.com --admin`, or `make reset` to start over, which destroys the encryption volume |
| The claim link says invalid or expired | 24 hour lifetime, single use, and it does not survive a Redis flush. Print a new one with `warmblyctl setup-link` |
| `/setup` says the instance is already set up | An account exists, so no claim link will ever be issued. `warmblyctl user reset-password --email ...`, or `warmblyctl user create --email you@example.com --admin` |
| An invited person gets `invitation_invalid` | The invitation expired (7 days by default), was cancelled, was already used, or they typed a different address than the one it was issued for. Re-invite them |
| An invited person gets `invitation_invalid` | The invitation expired (48 hours by default), was cancelled, was already used, or they typed a different address than the one it was issued for. Click **Resend** on its row under **Settings > Members**, or invite them again |
| Single sign-on succeeds at the provider then 404s | The redirect URI must be `<API_PUBLIC_URL>/v1/auth/oidc/callback`, not `/api/v1/...`. It has to match on both sides exactly. See [single sign-on](/development/accounts-and-access/#single-sign-on) |
| Single sign-on stopped provisioning new users | Provisioning now obeys `DISABLE_REGISTRATION`. Invite the person, or set `SSO_AUTO_PROVISION=true` and restart |
| No login code arrives | Self-host does not send one (`AUTH_LOGIN_CODE=off`). Registration and reset codes go wherever `MAIL_TRANSPORT` points; under compose that is the backend log |
+5 -1
View File
@@ -65,7 +65,11 @@ Warmbly sends one invitation per address and reports results as "Invited 2 · 1
When several trusted dashboards share one backend, invitation emails link back to the dashboard that sent the invitation. Copying an invitation link also keeps that dashboard's hostname. See [multi-dashboard configuration](/development/configuration/#google-sign-in-per-dashboard-deployment).
**Pending invitations** shows the email, role, access, and expiry for each unaccepted invite, with two actions on the right of the row: copy the invite link (to share directly instead of waiting on email) and cancel the invitation. There is no resend button. To give somebody a fresh link, invite the same address again: that replaces the invitation in place with a new token and a reset expiry, and the previously emailed and copied links stop working. Copying the link again also issues a new link, so only the most recently copied link works; the emailed link is unaffected.
An invitation works for 48 hours. A self-hosted instance can shorten that, but not lengthen it.
**Pending invitations** shows the email, role, access, and expiry for each unaccepted invite, with two actions on the right of the row: copy the invite link (to share directly instead of waiting on email) and cancel the invitation. Copying the link again issues a new link, so only the most recently copied link works; the emailed link is unaffected.
An invitation that ran out stays on the list marked **Expired**, with **Resend** in place of the copy action. Resend invites the same address again with the same roles and access: the invitation gets a new link and a fresh 48 hours, and every earlier link stops working. Inviting the address again from **Invite teammates** does the same thing. The list keeps the 50 most recently expired invitations; cancel one to remove it.
<Callout type="info" title="Roles are not permanent">
The invite-time role can be changed inline from the roster later, so it is fine to start people narrow and widen access afterward.
+25 -10
View File
@@ -40,15 +40,33 @@ type adminCreateTesterRequest struct {
// nobody chose.
OrgID *uuid.UUID `json:"organization_id"`
RoleID *uuid.UUID `json:"role_id"`
// PasswordDays is how long the handed-out password works; 0 means the default.
// PasswordHours is how long the handed-out password works; 0 means the default.
PasswordHours int `json:"password_hours"`
// PasswordDays is the older whole-day form, read when PasswordHours is unset.
PasswordDays int `json:"password_days"`
}
// An initial credential works for at most 48 hours.
const (
testerPasswordDefaultDays = 30
testerPasswordMaxDays = 90
testerPasswordDefaultHours = 48
testerPasswordMaxHours = 48
)
// testerPasswordLifetime resolves the requested lifetime, refusing one over the cap.
func testerPasswordLifetime(req adminCreateTesterRequest) (time.Duration, *errx.Error) {
hours := req.PasswordHours
if hours == 0 && req.PasswordDays != 0 {
hours = req.PasswordDays * 24
}
if hours == 0 {
hours = testerPasswordDefaultHours
}
if hours < 1 || hours > testerPasswordMaxHours {
return 0, errx.New(errx.BadRequest, "the password can be valid for 1 to 48 hours")
}
return time.Duration(hours) * time.Hour, nil
}
type adminCreateTesterResponse struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
@@ -115,15 +133,12 @@ func (h *Handler) AdminCreateTester(c *gin.Context) {
return
}
days := req.PasswordDays
if days == 0 {
days = testerPasswordDefaultDays
}
if days < 1 || days > testerPasswordMaxDays {
errx.JSON(c, errx.New(errx.BadRequest, "the password can be valid for 1 to 90 days"))
lifetime, lerr := testerPasswordLifetime(req)
if lerr != nil {
errx.JSON(c, lerr)
return
}
passwordExpiresAt := time.Now().Add(time.Duration(days) * 24 * time.Hour)
passwordExpiresAt := time.Now().Add(lifetime)
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"))
+21 -1
View File
@@ -59,7 +59,7 @@ func TestAdminCreateTesterOnlyGrantsDedicatedWorkspaces(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) { c.Set(middleware.AdminUserIDKey, actor) })
r.POST("/testers", h.AdminCreateTester)
req := adminCreateTesterRequest{Email: "reviewer@example.test", Reason: "OAuth review", PasswordDays: 7}
req := adminCreateTesterRequest{Email: "reviewer@example.test", Reason: "OAuth review", PasswordHours: 24}
if existing {
req.OrgID, req.RoleID = &orgID, &roleID
}
@@ -152,3 +152,23 @@ func TestAdminSeedTesterWorkspaceRequiresAuthorityAndValidTarget(t *testing.T) {
})
}
}
func TestTesterPasswordLifetimeIsAtMostTwoDays(t *testing.T) {
for _, tc := range []struct {
req adminCreateTesterRequest
want time.Duration
ok bool
}{
{adminCreateTesterRequest{}, 48 * time.Hour, true},
{adminCreateTesterRequest{PasswordHours: 6}, 6 * time.Hour, true},
{adminCreateTesterRequest{PasswordDays: 2}, 48 * time.Hour, true},
{adminCreateTesterRequest{PasswordHours: 49}, 0, false},
{adminCreateTesterRequest{PasswordDays: 30}, 0, false},
{adminCreateTesterRequest{PasswordHours: -1}, 0, false},
} {
got, err := testerPasswordLifetime(tc.req)
if (err == nil) != tc.ok || got != tc.want {
t.Errorf("%+v: got %v, err %v; want %v, ok %v", tc.req, got, err, tc.want, tc.ok)
}
}
}
+19 -1
View File
@@ -288,7 +288,7 @@ func (h *Handler) InviteMember(c *gin.Context) {
subject := fmt.Sprintf("You've been invited to join %s on %s", orgName, templates.CompanyName())
acceptURL := config.GetInviteURL(inv.Token, config.DashboardOriginFromContext(c.Request.Context()))
// GenerateInvitationHTML reports its own render errors.
if body, gerr := templates.GenerateInvitationHTML(inviterName, orgName, acceptURL); gerr == nil {
if body, gerr := templates.GenerateInvitationHTML(inviterName, orgName, acceptURL, time.Until(inv.ExpiresAt).Round(time.Hour)); gerr == nil {
// Detached from the request context: the handler returns before the
// send completes, and a cancelled context aborted the send mid-dial.
sendCtx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 45*time.Second)
@@ -411,11 +411,29 @@ func (h *Handler) GetPendingInvitations(c *gin.Context) {
return
}
includeExpired := false
switch c.Query("include_expired") {
case "", "false":
case "true":
includeExpired = true
default:
errx.JSON(c, errx.New(errx.BadRequest, "include_expired must be true or false"))
return
}
invitations, xerr := h.OrganizationService.GetPendingInvitations(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
if includeExpired {
expired, xerr := h.OrganizationService.GetExpiredInvitations(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
invitations = append(invitations, expired...)
}
c.JSON(http.StatusOK, gin.H{"data": invitations})
}
+4 -4
View File
@@ -6,12 +6,12 @@ import (
"github.com/warmbly/warmbly/internal/config"
)
// Bounds on the invitation lifetime. An hour is the shortest window a person
// can realistically act in; 30 days is the longest a bearer link should live.
// Bounds on the invitation lifetime. An invitation is an initial credential, so
// it lives at most 48 hours; a stored longer value is read back as 48.
const (
TTLHoursMin = 1
TTLHoursMax = 720
TTLHoursDefault = 168
TTLHoursMax = 48
TTLHoursDefault = 48
)
// Invitations holds the invite-flow knobs.
@@ -69,6 +69,21 @@ func TestDocumentUnmarshalOverDefaultsKeepsDeliverability(t *testing.T) {
}
}
// An invitation lives at most 48 hours, including one stored with a longer value.
func TestInvitationTTLIsAtMostTwoDays(t *testing.T) {
doc := Defaults()
if err := json.Unmarshal([]byte(`{"invitations":{"links_enabled":true,"ttl_hours":168}}`), &doc); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
doc.Normalize()
if got := doc.TTL(); got != 48*time.Hour {
t.Errorf("TTL() = %v, want 48h", got)
}
if got := Defaults().TTL(); got != 48*time.Hour {
t.Errorf("default TTL() = %v, want 48h", got)
}
}
// An operator turning the gate off is an explicit false, which must survive the
// round trip. A pointer field in the patch is what makes false distinguishable
// from absent.
+21 -1
View File
@@ -27,7 +27,7 @@ import (
const (
// InvitationTTL is the invitation expiration used when no instance
// settings service is wired; the operator's value overrides it.
InvitationTTL = 7 * 24 * time.Hour // 7 days
InvitationTTL = 48 * time.Hour
// InvitationTokenLength is the length of invitation tokens
InvitationTokenLength = 32
@@ -109,6 +109,7 @@ type OrganizationService interface {
// Invitations
GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, *errx.Error)
GetExpiredInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, *errx.Error)
GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, *errx.Error)
CancelInvitation(ctx context.Context, orgID, invitationID uuid.UUID) *errx.Error
@@ -1120,6 +1121,25 @@ func (s *organizationService) GetPendingInvitations(ctx context.Context, orgID u
return invitations, nil
}
// expiredInvitationsListed bounds the expired invitations offered for sending again.
const expiredInvitationsListed = 50
// GetExpiredInvitations returns an organization's recently expired invitations, marked expired.
func (s *organizationService) GetExpiredInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, *errx.Error) {
invitations, err := s.orgRepo.GetExpiredInvitations(ctx, orgID, expiredInvitationsListed)
if err != nil {
errs.CaptureException(err)
return nil, errx.New(errx.Internal, "failed to get invitations")
}
for i := range invitations {
invitations[i].Expired = true
}
if err := s.orgRepo.HydrateInvitationRoles(ctx, invitations); err != nil {
errs.CaptureException(err)
}
return invitations, nil
}
// GetUserPendingInvitations retrieves all pending invitations for a user
func (s *organizationService) GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, *errx.Error) {
invitations, err := s.orgRepo.GetUserPendingInvitations(ctx, email)
+2
View File
@@ -137,6 +137,8 @@ type OrganizationInvitation struct {
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
Access *MemberAccess `json:"access,omitempty"`
// Expired is set on rows listed for sending again; such an invitation grants nothing.
Expired bool `json:"expired,omitempty"`
// Joined data
Organization *Organization `json:"organization,omitempty"`
+6 -4
View File
@@ -3,6 +3,7 @@ package templates
import (
"bytes"
"html/template"
"time"
"github.com/warmbly/warmbly/internal/observability/errs"
)
@@ -33,22 +34,23 @@ You've been invited to {{.OrgName}}
<div style="margin:0 0 16px;height:1px;background:#e2e8f0;"></div>
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:12px;color:#64748b;line-height:18px;">
This invitation expires in 7 days. If you don't have an account yet, you can create one when you accept.
This invitation expires in {{.Expiry}}. If you don't have an account yet, you can create one when you accept.
</p>
`
var invitationTmpl = template.Must(template.New("invitation_content").Parse(invitationContent))
// GenerateInvitationHTML renders a team-invitation email. acceptURL is
// the full invite-accept link (already carrying the token).
func GenerateInvitationHTML(inviterName, orgName, acceptURL string) (string, error) {
// the full invite-accept link (already carrying the token); ttl is its remaining lifetime.
func GenerateInvitationHTML(inviterName, orgName, acceptURL string, ttl time.Duration) (string, error) {
brand := CompanyName()
data := struct {
InviterName string
OrgName string
AcceptURL string
Brand string
}{InviterName: inviterName, OrgName: orgName, AcceptURL: acceptURL, Brand: brand}
Expiry string
}{InviterName: inviterName, OrgName: orgName, AcceptURL: acceptURL, Brand: brand, Expiry: humanExpiry(ttl)}
var buf bytes.Buffer
if err := invitationTmpl.Execute(&buf, data); err != nil {
errs.CaptureException(err)
+10 -8
View File
@@ -342,7 +342,7 @@ func TestPreview(t *testing.T) {
}},
{"trial-expired.html", func() (string, error) { return GenerateTrialExpiredHTML() }},
{"invitation.html", func() (string, error) {
return GenerateInvitationHTML("Jane Doe", "Acme Inc", AppURL()+"/invite?token=abc123")
return GenerateInvitationHTML("Jane Doe", "Acme Inc", AppURL()+"/invite?token=abc123", 48*time.Hour)
}},
{"notification.html", func() (string, error) {
return GenerateNotificationHTML("New sign-in to your account", "Signed in from Chrome on macOS (London, GB).", AppURL()+"/app/settings/security", "")
@@ -402,7 +402,7 @@ func TestGenerateTrialExpiredHTML(t *testing.T) {
func TestGenerateInvitationHTML(t *testing.T) {
url := AppURL() + "/invite?token=abc123"
html, err := GenerateInvitationHTML("Jane Doe", "Acme Inc", url)
html, err := GenerateInvitationHTML("Jane Doe", "Acme Inc", url, 48*time.Hour)
if err != nil {
t.Fatalf("GenerateInvitationHTML returned error: %v", err)
}
@@ -412,7 +412,7 @@ func TestGenerateInvitationHTML(t *testing.T) {
"Acme Inc",
"Accept invitation</a>",
url,
"expires in 7 days",
"expires in 48 hours",
}
for _, s := range checks {
if !strings.Contains(html, s) {
@@ -422,7 +422,7 @@ func TestGenerateInvitationHTML(t *testing.T) {
}
func TestGenerateInvitationHTML_EscapesNames(t *testing.T) {
html, err := GenerateInvitationHTML("<script>evil()</script>", "Acme & Co", AppURL()+"/invite?token=x")
html, err := GenerateInvitationHTML("<script>evil()</script>", "Acme & Co", AppURL()+"/invite?token=x", time.Hour)
if err != nil {
t.Fatalf("GenerateInvitationHTML returned error: %v", err)
}
@@ -591,10 +591,12 @@ func TestTemplatesUseTheConfiguredName(t *testing.T) {
t.Setenv("APP_URL", "https://app.acme.example")
cases := map[string]func() (string, error){
"welcome": func() (string, error) { return GenerateWelcomeHTML("Jane") },
"digest": func() (string, error) { return GenerateDigestHTML(3, []DigestItem{{Title: "One"}}) },
"trial expired": func() (string, error) { return GenerateTrialExpiredHTML() },
"invitation": func() (string, error) { return GenerateInvitationHTML("Jane", "Acme Inc", "https://x/invite") },
"welcome": func() (string, error) { return GenerateWelcomeHTML("Jane") },
"digest": func() (string, error) { return GenerateDigestHTML(3, []DigestItem{{Title: "One"}}) },
"trial expired": func() (string, error) { return GenerateTrialExpiredHTML() },
"invitation": func() (string, error) {
return GenerateInvitationHTML("Jane", "Acme Inc", "https://x/invite", 48*time.Hour)
},
"notification": func() (string, error) { return GenerateNotificationHTML("Hi", "Body", "https://x", "") },
"registration code": func() (string, error) { return GenerateRegistrationCodeHTML("123456") },
"user deletion": func() (string, error) { return GenerateUserDeletionScheduledHTML("Jane", previewTime, 30, "https://x") },
@@ -71,6 +71,13 @@ func TestLiveInvitationAcceptConsumesOnce(t *testing.T) {
if inv, _ := repo.GetInvitationByID(ctx, id); inv == nil {
t.Fatal("an expired invitation was removed, so it cannot be sent again")
}
expired, err := repo.GetExpiredInvitations(ctx, org, 50)
if err != nil || len(expired) != 1 || expired[0].ID != id {
t.Fatalf("expired invitations = %v, %v; want the one just expired", expired, err)
}
if pending, _ := repo.GetPendingInvitations(ctx, org); len(pending) != 0 {
t.Fatal("an expired invitation is listed as pending")
}
exec(`DELETE FROM organization_invitations WHERE id = $1`, id)
})
+20 -2
View File
@@ -82,6 +82,7 @@ type OrganizationRepository interface {
GetInvitationByID(ctx context.Context, id uuid.UUID) (*models.OrganizationInvitation, error)
GetInvitationByEmail(ctx context.Context, orgID uuid.UUID, email string) (*models.OrganizationInvitation, error)
GetPendingInvitations(ctx context.Context, orgID uuid.UUID) ([]models.OrganizationInvitation, error)
GetExpiredInvitations(ctx context.Context, orgID uuid.UUID, limit int) ([]models.OrganizationInvitation, error)
GetUserPendingInvitations(ctx context.Context, email string) ([]models.OrganizationInvitation, error)
DeleteInvitation(ctx context.Context, id uuid.UUID) error
DeleteExpiredInvitations(ctx context.Context) error
@@ -545,7 +546,24 @@ func (r *organizationRepository) GetPendingInvitations(ctx context.Context, orgI
WHERE organization_id = $1 AND expires_at > NOW()
ORDER BY created_at DESC
`
rows, err := r.db.Query(ctx, query, orgID)
return r.queryInvitations(ctx, query, orgID)
}
// GetExpiredInvitations returns an organization's most recently expired invitations, so they can be sent again.
func (r *organizationRepository) GetExpiredInvitations(ctx context.Context, orgID uuid.UUID, limit int) ([]models.OrganizationInvitation, error) {
query := `
SELECT id, organization_id, email, role, role_id, permissions, invited_by, token, expires_at, created_at,
` + invitationAccessCols + `
FROM organization_invitations i
WHERE organization_id = $1 AND expires_at <= NOW()
ORDER BY expires_at DESC
LIMIT $2
`
return r.queryInvitations(ctx, query, orgID, limit)
}
func (r *organizationRepository) queryInvitations(ctx context.Context, query string, args ...any) ([]models.OrganizationInvitation, error) {
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -563,7 +581,7 @@ func (r *organizationRepository) GetPendingInvitations(ctx context.Context, orgI
inv.Access = acc.access()
invitations = append(invitations, inv)
}
return invitations, nil
return invitations, rows.Err()
}
// GetUserPendingInvitations retrieves all pending invitations for a user's email
+1 -1
View File
@@ -20,7 +20,7 @@ test("add sample data to a dedicated Test workspace once", async ({ page, proof
const email = `sample-proof-${Date.now()}@example.test`;
const created = await page.request.post(`${env.apiURL}/admin/testers`, {
headers,
data: { email, org_name: "Sample review proof", reason: "Local sample-data proof", password_days: 30 },
data: { email, org_name: "Sample review proof", reason: "Local sample-data proof", password_hours: 48 },
});
expect(created.status()).toBe(201);
const { organization_id: orgID, user_id: userID } = await created.json() as { organization_id: string; user_id: string };
+73 -15
View File
@@ -31,6 +31,7 @@ import useUpdateMemberRole from "@/lib/api/hooks/app/organizations/useUpdateMemb
import useRoles from "@/lib/api/hooks/app/organizations/useRoles";
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
import type OrganizationRole from "@/lib/api/models/app/organizations/OrganizationRole";
import type Invitation from "@/lib/api/models/app/organizations/Invitation";
import { useAppStore } from "@/stores";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -72,6 +73,7 @@ export default function MembersSettingsPage() {
const memberList = members.data ?? [];
const inviteList = invites.data ?? [];
const expiredCount = inviteList.filter((i) => i.expired).length;
function doRemove(id: string, label: string) {
confirm?.show(`Remove ${label}?`, async () => {
@@ -99,6 +101,23 @@ export default function MembersSettingsPage() {
}
});
}
const [resending, setResending] = React.useState<string | null>(null);
// Inviting the same address again replaces the expired row with a fresh link and expiry.
async function resendInvite(inv: Invitation) {
const roleIds = inv.roles?.length ? inv.roles.map((r) => r.id) : inv.role_id ? [inv.role_id] : undefined;
setResending(inv.id);
try {
await toast.promise(invite.mutateAsync({ email: inv.email, role_ids: roleIds, access: inv.access }), {
loading: "Sending…",
success: `Invitation sent again to ${inv.email}`,
error: (e: AppError) => buildError(e),
});
} catch {
/* surfaced */
} finally {
setResending(null);
}
}
async function copyInviteLink(invitationId: string) {
// Each copy mints a new link and retires the last, so the write starts inside the click:
// ClipboardItem takes the pending URL, which keeps Safari's user gesture.
@@ -300,7 +319,11 @@ export default function MembersSettingsPage() {
<Section
eyebrow="Pending invitations"
description={`${inviteList.length} awaiting acceptance.`}
description={
expiredCount > 0
? `${inviteList.length - expiredCount} awaiting acceptance, ${expiredCount} expired. An invitation works for up to 48 hours.`
: `${inviteList.length} awaiting acceptance. An invitation works for up to 48 hours.`
}
>
{invites.isPending ? (
<p className="text-[11.5px] text-slate-400 py-2">Loading…</p>
@@ -316,7 +339,7 @@ export default function MembersSettingsPage() {
<Th className="w-32">Role</Th>
<Th className="md:w-48">Access</Th>
<Th className="w-40 hidden md:table-cell">Expires</Th>
<th className="w-16 md:w-32 px-3 py-2"></th>
<th className="w-28 md:w-32 px-3 py-2"></th>
</tr>
</thead>
<tbody>
@@ -328,9 +351,14 @@ export default function MembersSettingsPage() {
<td className="px-4">
<div className="flex items-center gap-2.5">
<MailIcon className="w-3.5 h-3.5 text-slate-400" />
<span className="text-[12.5px] text-slate-900 truncate">
<span className={`text-[12.5px] truncate ${inv.expired ? "text-slate-500" : "text-slate-900"}`}>
{inv.email}
</span>
{inv.expired && (
<span className="md:hidden">
<ExpiredBadge />
</span>
)}
</div>
</td>
<td className="px-3">
@@ -340,26 +368,48 @@ export default function MembersSettingsPage() {
<AccessBadge access={inv.access} />
</td>
<td className="px-3 font-mono text-[11px] text-slate-500 tabular-nums hidden md:table-cell">
{new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
{inv.expired ? (
<ExpiredBadge />
) : (
new Date(inv.expires_at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
)}
</td>
<td className="px-3">
{access.canManage && (
<div className="flex items-center gap-0.5 justify-end opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => copyInviteLink(inv.id)}
aria-label="Copy invite link"
title="Copy invite link"
className="size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<CopyIcon className="w-3 h-3" />
</button>
<div className="flex items-center gap-0.5 justify-end">
{inv.expired ? (
<button
type="button"
onClick={() => resendInvite(inv)}
disabled={resending === inv.id}
aria-label="Send invitation again"
title="Send invitation again"
className="h-6 px-2 rounded-md border border-slate-200 text-[11.5px] text-slate-700 hover:border-sky-300 hover:text-sky-700 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors disabled:opacity-50"
>
{resending === inv.id ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<SendIcon className="w-3 h-3" />
)}
Resend
</button>
) : (
<button
type="button"
onClick={() => copyInviteLink(inv.id)}
aria-label="Copy invite link"
title="Copy invite link"
className="size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors opacity-100 md:opacity-0 md:group-hover:opacity-100"
>
<CopyIcon className="w-3 h-3" />
</button>
)}
<button
type="button"
onClick={() => doCancelInvite(inv.id)}
aria-label="Cancel invitation"
title="Cancel invitation"
className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors"
className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors opacity-100 md:opacity-0 md:group-hover:opacity-100"
>
<TrashIcon className="w-3 h-3" />
</button>
@@ -378,6 +428,14 @@ export default function MembersSettingsPage() {
);
}
function ExpiredBadge() {
return (
<span className="inline-flex items-center h-[18px] px-1.5 rounded bg-amber-50 text-amber-700 text-[10.5px] font-medium font-sans">
Expired
</span>
);
}
function Th({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<th
@@ -8,7 +8,7 @@ interface RawResponse {
export default async function getPendingInvitations(): Promise<Invitation[]> {
const res = await Request<RawResponse | Invitation[]>({
method: "GET",
url: `/organization/invitations`,
url: `/organization/invitations?include_expired=true`,
authorization: true,
});
if (Array.isArray(res)) return res;
@@ -13,4 +13,6 @@ export default interface Invitation {
created_at: Date
expires_at: Date
access?: MemberAccess
// Listed so it can be sent again; it grants nothing.
expired?: boolean
}