diff --git a/admin/src/app/dashboard/TestersPage.tsx b/admin/src/app/dashboard/TestersPage.tsx index 1cd46568b..0a98b7bc8 100644 --- a/admin/src/app/dashboard/TestersPage.tsx +++ b/admin/src/app/dashboard/TestersPage.tsx @@ -50,7 +50,7 @@ export default function TestersPage() { const [orgQuery, setOrgQuery] = useState(""); const [org, setOrg] = useState(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(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() { - setPasswordHours(Number(v))}> - {[7, 14, 30, 60, 90].map((d) => ( + {[4, 12, 24, 48].map((d) => ( - {d} days + {d} hours ))} diff --git a/admin/src/app/dashboard/configuration/SettingsTab.tsx b/admin/src/app/dashboard/configuration/SettingsTab.tsx index efeacdc95..8912691d5 100644 --- a/admin/src/app/dashboard/configuration/SettingsTab.tsx +++ b/admin/src/app/dashboard/configuration/SettingsTab.tsx @@ -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} diff --git a/admin/src/lib/api/client/admin/testers.ts b/admin/src/lib/api/client/admin/testers.ts index 007ac598e..eea502fa2 100644 --- a/admin/src/lib/api/client/admin/testers.ts +++ b/admin/src/lib/api/client/admin/testers.ts @@ -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 { return Request({ method: "POST", url: "/admin/testers", authorization: true, data: body }); } diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 1f49f84f4..09dda3132 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -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 diff --git a/docs/content/docs/development/accounts-and-access.mdx b/docs/content/docs/development/accounts-and-access.mdx index 1647dc651..202be6687 100644 --- a/docs/content/docs/development/accounts-and-access.mdx +++ b/docs/content/docs/development/accounts-and-access.mdx @@ -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. - -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. + +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. 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 | diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index fa7cfbddf..a5db8a702 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -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 | diff --git a/docs/content/docs/development/troubleshooting.mdx b/docs/content/docs/development/troubleshooting.mdx index 1aa76bf6d..c71f213cc 100644 --- a/docs/content/docs/development/troubleshooting.mdx +++ b/docs/content/docs/development/troubleshooting.mdx @@ -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 `/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 | diff --git a/docs/content/docs/guides/team-roles.mdx b/docs/content/docs/guides/team-roles.mdx index db4a24b49..29c640ad7 100644 --- a/docs/content/docs/guides/team-roles.mdx +++ b/docs/content/docs/guides/team-roles.mdx @@ -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. The invite-time role can be changed inline from the roster later, so it is fine to start people narrow and widen access afterward. diff --git a/internal/api/handler/admin_tester.go b/internal/api/handler/admin_tester.go index 0d8803aa1..0d4f42a7e 100644 --- a/internal/api/handler/admin_tester.go +++ b/internal/api/handler/admin_tester.go @@ -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")) diff --git a/internal/api/handler/admin_tester_test.go b/internal/api/handler/admin_tester_test.go index b651977e2..c2c564b72 100644 --- a/internal/api/handler/admin_tester_test.go +++ b/internal/api/handler/admin_tester_test.go @@ -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) + } + } +} diff --git a/internal/api/handler/organization.go b/internal/api/handler/organization.go index 31e668f97..03405c765 100644 --- a/internal/api/handler/organization.go +++ b/internal/api/handler/organization.go @@ -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}) } diff --git a/internal/app/instancesettings/document.go b/internal/app/instancesettings/document.go index 43f4aca26..a104b4356 100644 --- a/internal/app/instancesettings/document.go +++ b/internal/app/instancesettings/document.go @@ -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. diff --git a/internal/app/instancesettings/document_test.go b/internal/app/instancesettings/document_test.go index 2c0f6491c..ae286ea23 100644 --- a/internal/app/instancesettings/document_test.go +++ b/internal/app/instancesettings/document_test.go @@ -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. diff --git a/internal/app/organization/service.go b/internal/app/organization/service.go index 2b5ad78e1..5a70ce651 100644 --- a/internal/app/organization/service.go +++ b/internal/app/organization/service.go @@ -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) diff --git a/internal/models/organization.go b/internal/models/organization.go index dbddab40b..ae3ff7dbd 100644 --- a/internal/models/organization.go +++ b/internal/models/organization.go @@ -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"` diff --git a/internal/notify/templates/invitation.go b/internal/notify/templates/invitation.go index 4b8d05760..16204eb45 100644 --- a/internal/notify/templates/invitation.go +++ b/internal/notify/templates/invitation.go @@ -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}}

-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.

` 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) diff --git a/internal/notify/templates/templates_test.go b/internal/notify/templates/templates_test.go index 61d4cbac3..0ec90e797 100644 --- a/internal/notify/templates/templates_test.go +++ b/internal/notify/templates/templates_test.go @@ -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", 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("", "Acme & Co", AppURL()+"/invite?token=x") + html, err := GenerateInvitationHTML("", "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") }, diff --git a/internal/repository/invitation_accept_live_test.go b/internal/repository/invitation_accept_live_test.go index 80e1f9073..aa9eb9134 100644 --- a/internal/repository/invitation_accept_live_test.go +++ b/internal/repository/invitation_accept_live_test.go @@ -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) }) diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index a363f9751..75446e1af 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -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 diff --git a/qa/flows/tester-sample-data.flow.ts b/qa/flows/tester-sample-data.flow.ts index a6da8b5bb..3f5f40f41 100644 --- a/qa/flows/tester-sample-data.flow.ts +++ b/qa/flows/tester-sample-data.flow.ts @@ -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 }; diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 612d5cbed..e8f20d9d2 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -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(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() {
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 ? (

Loading…

@@ -316,7 +339,7 @@ export default function MembersSettingsPage() { Role Access Expires - + @@ -328,9 +351,14 @@ export default function MembersSettingsPage() {
- + {inv.email} + {inv.expired && ( + + + + )}
@@ -340,26 +368,48 @@ export default function MembersSettingsPage() { - {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} + {inv.expired ? ( + + ) : ( + new Date(inv.expires_at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) + )} {access.canManage && ( -
- +
+ {inv.expired ? ( + + ) : ( + + )} @@ -378,6 +428,14 @@ export default function MembersSettingsPage() { ); } +function ExpiredBadge() { + return ( + + Expired + + ); +} + function Th({ children, className }: { children: React.ReactNode; className?: string }) { return ( { const res = await Request({ method: "GET", - url: `/organization/invitations`, + url: `/organization/invitations?include_expired=true`, authorization: true, }); if (Array.isArray(res)) return res; diff --git a/web/src/lib/api/models/app/organizations/Invitation.ts b/web/src/lib/api/models/app/organizations/Invitation.ts index ea23e5f3d..ca4e6ef90 100644 --- a/web/src/lib/api/models/app/organizations/Invitation.ts +++ b/web/src/lib/api/models/app/organizations/Invitation.ts @@ -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 }