feat(members): always return the company invite link so it can be shared without a mail provider (#1710) (#2020)

POST /api/company/members/invite now returns the accept link alongside email_sent and user_provisioned (the NODE_ENV=development gate from #153 is gone, so self-hosted Docker operators without Resend can invite colleagues). CompanyMembersSection ports the TeamPanel share-link pattern: an attention line with a copy action when the mail did not go out, a quiet line otherwise. Docs cover invites without Resend and the revoke-and-re-invite rule. Refs #1710 (SMTP delivery itself is #1746).
This commit is contained in:
Jakob Wennberg
2026-08-30 11:53:13 +02:00
committed by GitHub
parent 521f437072
commit 8ec12fefc7
8 changed files with 214 additions and 26 deletions
+2
View File
@@ -1338,6 +1338,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
[2026-08-28] Company invite accept link returned in-band to the inviter (always, not only under NODE_ENV=development) rather than printed to the server log as #1710 suggested: tokens are SHA-256 hashed at rest (lib/auth/invite-tokens.ts), so the raw link exists exactly once, in the create response, and the inviter is the person who needs it; a log line would put a bearer credential in log storage and still require shell access to the container. Same contract POST /api/team/invite already ships (email_sent + inviteUrl, TeamPanel shareInvite). Acceptance stays email-bound (app/api/team/accept/route.ts requires the signed-in user's email to match the invitation, per the 2026-07-24 line above: invite recovery stays token/cookie based with mailbox possession), so sharing the link over another channel does not widen who can claim the membership. No re-send endpoint added for company invites (revoke + re-invite gives a fresh link; a re-send would be a separate surface). SMTP as a delivery path is deferred to #1746.
[2026-08-30] Company invite toast title branches on whether mail actually went out ("Inbjudan skapad" when no provider is configured or the send failed, "Inbjudan skickad" otherwise) instead of the inherited TeamPanel wording that says sent next to a not-sent description; the share-link live region in CompanyMembersSection is always mounted (empty until a link exists) so screen readers announce the line when it appears, same reason as the roster block. TeamPanel keeps its copied wording and conditionally mounted region on purpose: the diff stays scoped to the company path (#1710), parity is a small follow-up.
[2026-08-29] Migrated invoices are linked to their REGISTRATION voucher only (PR A of #1463; payment vouchers stay with bulk-reconcile-supplier-vouchers and a later PR): the provider names the booking voucher on the invoice (Visma `VoucherNumber` "A329", Fortnox `VoucherSeries` + `VoucherNumber`; `VoucherYear` is ignored, the invoice date picks the fiscal year), the SIE import preserved that source ref on `journal_entries.source_voucher_*`, and lib/invoices/link-migrated-registration-vouchers.ts joins the two through the existing voucher-ref-resolver. A link is written only when the ref resolves to exactly ONE posted verifikat in the invoice's fiscal year, its net credit on 244x (supplier) or net debit on 151x (customer) equals `total_sek` within 0.005, and no invoice already references it; a verifikat with no 244x/151x line at all (kontantmetod books on payment, or the provider named a payment voucher) is reported `unresolved`, never linked, and a credit note whose sign does not corroborate stays unlinked rather than being matched on absolute value. The only writes are `supplier_invoices.registration_journal_entry_id` / `invoices.journal_entry_id`, from NULL, company-scoped; journal tables are never touched. Nothing stores the provider ref on the invoice row, so the /reconcile re-run (`{ consentId }`) re-fetches both registers from the provider and joins on invoice number (sales; UNIQUE per company) or supplier invoice number + date (unique on both sides) before handing the pairs to the same linker: a stored ref would have been cheaper to re-run but is a schema change this PR deliberately avoids.
[2026-08-30] Registration-voucher linker rejects a verifikat dated outside 14 days before / 90 days after the invoice date, and does NOT fall back to the following fiscal year: source systems restart numbering per year and the invoice date picks the year on our side, so a December invoice booked in January resolves to the previous January's same-numbered voucher, which a recurring amount can corroborate by coincidence. The corridor closes that wrong link cheaply (VoucherRow already carries entry_date); the cut-off invoice itself stays NULL (`unresolved`) rather than being resolved in the next year, because the same coincidence would then apply to Q4 invoices whose true voucher the SIE import skipped. Fortnox invoices whose detail form was never hydrated are reported `refNotFetched`, not `noRef`: the list form never carries VoucherSeries/VoucherNumber, so "provider reported no voucher" would be untrue. Foreign-currency invoices stay in `amountMismatch` with a reason naming the rate difference (our Riksbanken `total_sek` vs the source's own rate) instead of a rate-tolerant match: a tolerant match would need the source rate, which SIE4 does not carry. The /reconcile relink joins BOTH registers on invoice number AND invoice date and skips sales drafts: it reads every NULL-link row in the company, so native invoices sit next to migrated ones and a number alone could hand the linker a native invoice that reuses a provider number. The consent is validated (company-scoped, getConsent) BEFORE the payment reconcile writes, so a wrong consentId is a clean 404 instead of a 500 after a persisted write, and a relink failure is returned as `registrationLinksError` beside the payment result rather than discarding it. The two hardcoded Swedish progress labels follow the five sibling steps and are left for a joint i18n follow-up; the relink stays API-only (no workspace button) because UI changes need a visual sign-off.
[2026-08-29] get_vat_ruta_source_lines ACL restored in a NEW migration (20260829090500) rather than by editing 20260828172003: that file DROPped the 9-arg overload and CREATEd the 11-arg one without restating REVOKE/GRANT, and DROP FUNCTION discards the ACL, so the new signature silently fell back to EXECUTE for PUBLIC (anon included); the migration is already applied on prod, so a follow-up file is the only compliant path. Rule going forward: every DROP + CREATE of an RPC must restate its REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated, service_role, and tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts now pins it with has_function_privilege (anon false, authenticated and service_role true, exactly one overload).
@@ -100,6 +100,7 @@ beforeEach(() => {
})
afterEach(() => {
vi.unstubAllEnvs()
delete process.env.AUTH_SIGNUPS_DISABLED
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
@@ -154,7 +155,7 @@ describe('POST /api/company/members/invite', () => {
enqueue({ data: null }) // insert invitation
const { status, body } = await parseJsonResponse<{
data: { email: string; email_sent: boolean }
data: { email: string; email_sent: boolean; inviteUrl: string }
}>(await post({ email: 'Client@Example.com', role: 'viewer' }))
expect(status).toBe(200)
@@ -163,6 +164,10 @@ describe('POST /api/company/members/invite', () => {
expect(sendEmailMock).toHaveBeenCalledWith(
expect.objectContaining({ to: 'client@example.com' })
)
// The accept link is always returned (vitest runs with NODE_ENV=test,
// so this pins the removal of the old development-only gate): the
// inviter can share it directly even when the mail went out.
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
})
it('sends the branded invite: brand link base, brand appName, brand sender', async () => {
@@ -180,7 +185,7 @@ describe('POST /api/company/members/invite', () => {
enqueue({ data: { name: 'Kund AB' } })
enqueue({ data: null })
const { status } = await parseJsonResponse(
const { status, body } = await parseJsonResponse<{ data: { inviteUrl: string } }>(
await post({ email: 'client@example.com' })
)
@@ -191,6 +196,8 @@ describe('POST /api/company/members/invite', () => {
appName: 'Siffra',
})
)
// The returned link follows the brand base too, same as the mailed one.
expect(body.data.inviteUrl).toBe('https://app.siffra.se/invite/tok-plain')
expect(sendEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
fromName: 'Siffra',
@@ -233,12 +240,54 @@ describe('POST /api/company/members/invite', () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'smtp down' })
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean; status: string }
data: { email_sent: boolean; status: string; inviteUrl: string }
}>(await post({ email: 'client@example.com' }))
expect(status).toBe(200)
expect(body.data.status).toBe('pending')
expect(body.data.email_sent).toBe(false)
// The link is the inviter's recovery path when the mail bounced.
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
})
it('no mail provider configured: invite created, link returned, nothing sent, warn logged (#1710)', async () => {
// NODE_ENV=production is the Docker image's setting and exactly the case
// the old development-only gate hid the link from. It also lets the
// logger emit (info/warn are suppressed under NODE_ENV=test).
vi.stubEnv('NODE_ENV', 'production')
isConfiguredMock.mockReturnValue(false)
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Acme AB' } })
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{
data: {
email_sent: boolean
user_provisioned: boolean
status: string
inviteUrl: string
}
}>(await post({ email: 'client@example.com' }))
expect(status).toBe(200)
expect(body.data.status).toBe('pending')
expect(body.data.email_sent).toBe(false)
expect(body.data.user_provisioned).toBe(false)
// Self-hosted without Resend: the in-band link is the ONLY way the
// invitee can ever accept, so it must be present outside development.
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
expect(sendEmailMock).not.toHaveBeenCalled()
const warned = consoleWarnSpy.mock.calls
.flat()
.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg)))
.join(' ')
expect(warned).toContain('email service not configured')
// The raw token never reaches the log: it is stored hashed at rest.
expect(warned).not.toContain('tok-plain')
consoleWarnSpy.mockRestore()
})
it('uses a registered white-label request host in the invitation email', async () => {
@@ -466,4 +515,33 @@ describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning',
expect(body.data.user_provisioned).toBe(false)
expect(body.data.email_sent).toBe(true)
})
it('flag on + no account + no mail provider: GoTrue provisions and mails, app sends nothing, link still returned', async () => {
process.env.AUTH_SIGNUPS_DISABLED = 'true'
isConfiguredMock.mockReturnValue(false)
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
enqueue({ data: { role: 'owner' } }) // caller membership
enqueue({ data: [] }) // existing members
enqueue({ data: null }) // existing invite
enqueue({ data: { name: 'Acme AB' } }) // company name
enqueue({ data: false }) // rpc check_email_exists -> no account
enqueue({ data: null }) // insert invitation
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean; user_provisioned: boolean; inviteUrl: string }
}>(await post({ email: 'client@example.com' }))
expect(status).toBe(200)
expect(inviteUserByEmailMock).toHaveBeenCalledWith('client@example.com', {
redirectTo: 'https://app.accounted.test/invite/tok-plain',
})
// email_sent reports the APP's mail only: GoTrue sent its own invite
// mail via the Supabase SMTP settings, so the UI must not call this a
// failed send (it branches on email_sent === false && !user_provisioned).
expect(body.data.user_provisioned).toBe(true)
expect(body.data.email_sent).toBe(false)
expect(sendEmailMock).not.toHaveBeenCalled()
expect(body.data.inviteUrl).toBe('https://app.accounted.test/invite/tok-plain')
consoleWarnSpy.mockRestore()
})
})
+15 -8
View File
@@ -43,6 +43,14 @@ function maskEmail(email: string): string {
* POST /api/company/members/invite
* Invite a user to the current company (e.g., a client as viewer).
* Only company owners and admins can invite.
*
* The accept link (inviteUrl) is ALWAYS returned in the response, whether or
* not the invitation email went out, so the inviter can share it directly.
* This is the only place the raw link exists: tokens are stored hashed
* (lib/auth/invite-tokens.ts), so a self-hosted operator without a mail
* provider (#1710) or an inviter whose mail bounced would otherwise hold an
* invitation nobody can accept. There is no re-send for company invites:
* revoke and invite again to get a fresh link.
*/
export const POST = withRouteContext(
'company_members.invite',
@@ -50,7 +58,7 @@ export const POST = withRouteContext(
const { companyId, user, log } = ctx
const serviceClient = await createServiceClient()
// Check caller has permission (owner/admin — stricter than requireWrite)
// Check caller has permission (owner/admin: stricter than requireWrite)
const { data: callerMembership } = await serviceClient
.from('company_members')
.select('role')
@@ -220,11 +228,10 @@ export const POST = withRouteContext(
// before.
const sender = await getSenderForCompany(companyId)
const appUrl = sender.brand ? getBaseUrlForBrand(sender.brand) : appOrigin
const inviteUrl = `${appUrl}/invite/${token}`
const emailService = getEmailService()
let emailSent = false
if (emailService.isConfigured()) {
const inviteUrl = `${appUrl}/invite/${token}`
const emailData = {
companyName: company?.name || 'Företag',
inviterEmail: user.email || '',
@@ -252,17 +259,17 @@ export const POST = withRouteContext(
log.warn('email service not configured: invite email skipped', { to: email })
}
// In development, return the invite URL directly (no email service)
const isDev = process.env.NODE_ENV === 'development'
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
return NextResponse.json({
data: {
email,
status: 'pending',
email_sent: emailSent,
user_provisioned: userProvisioned,
...(isDev && { inviteUrl: devInviteUrl }),
// The accept link is always returned so the inviter can share it
// directly, e.g. when the mail bounced or no mail provider is
// configured (self-hosted without Resend). Same contract as
// POST /api/team/invite.
inviteUrl,
},
})
},
+85 -10
View File
@@ -36,6 +36,22 @@ interface CompanyInvitation {
created_at: string
}
/**
* The shareable accept link from the latest invite response. Raw tokens are
* never stored server-side (only their hash), so the link exists exactly
* once: here, until the next navigation. Kept visible so a failed or absent
* mail send (self-hosted without a mail provider, #1710) never dead-ends the
* inviter. There is no re-send for company invites: revoke and re-invite.
* provisioned = GoTrue created the account and sent its own invite mail
* (AUTH_SIGNUPS_DISABLED path), so email_sent=false is not a failure there.
*/
interface ShareableInvite {
email: string
url: string
sent: boolean
provisioned: boolean
}
export function CompanyMembersSection() {
const t = useTranslations('settings_company')
const errorLocale = useLocale() as ErrorLocale
@@ -65,6 +81,7 @@ export function CompanyMembersSection() {
const [removingId, setRemovingId] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [canInvite, setCanInvite] = useState(false)
const [shareInvite, setShareInvite] = useState<ShareableInvite | null>(null)
const fetchMembers = useCallback(async () => {
setLoadError(null)
@@ -132,14 +149,27 @@ export function CompanyMembersSection() {
return
}
if (data.data.inviteUrl) {
console.log('[DEV] Company invite URL:', data.data.inviteUrl)
const payload = (
data as {
data?: { email_sent?: boolean; user_provisioned?: boolean; inviteUrl?: string }
} | null
)?.data
const sent = payload?.email_sent !== false
const provisioned = payload?.user_provisioned === true
// Persist the shareable link next to the pending list: a failed or
// skipped send leaves the invitation valid, and the toast alone is too
// easy to miss, so the recovery path stays visible on the page.
if (payload?.inviteUrl) {
setShareInvite({ email, url: payload.inviteUrl, sent, provisioned })
}
// The title must not claim a send that never happened (self-hosted
// without a mail provider): 'created' when nothing was mailed.
toast({
title: t('members_invite_sent_title'),
description: data.data.inviteUrl
? t('members_invite_sent_dev_url')
: t('members_invite_sent_description', { email }),
title: sent || provisioned ? t('members_invite_sent_title') : t('members_invite_created_title'),
description:
sent || provisioned
? t('members_invite_sent_description', { email })
: t('members_invite_mail_not_sent'),
})
setInviteEmail('')
setInviteRole('viewer')
@@ -151,6 +181,15 @@ export function CompanyMembersSection() {
}
}
const handleCopyInviteLink = async (url: string) => {
try {
await navigator.clipboard.writeText(url)
toast({ title: t('members_invite_link_copied_toast') })
} catch {
toast({ title: t('members_invite_link_copy_failed'), variant: 'destructive' })
}
}
const handleRemoveMember = async (memberId: string) => {
setRemovingId(memberId)
try {
@@ -171,10 +210,10 @@ export function CompanyMembersSection() {
}
}
const handleRevokeInvite = async (inviteId: string) => {
setRevokingId(inviteId)
const handleRevokeInvite = async (invite: CompanyInvitation) => {
setRevokingId(invite.id)
try {
const res = await fetch(`/api/company/members/invite/${inviteId}`, { method: 'DELETE' })
const res = await fetch(`/api/company/members/invite/${invite.id}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) {
@@ -182,6 +221,8 @@ export function CompanyMembersSection() {
return
}
// A revoked invitation's link is dead: never keep offering it.
setShareInvite((current) => (current?.email === invite.email ? null : current))
toast({ title: t('members_invite_revoked') })
fetchMembers()
} catch {
@@ -286,7 +327,7 @@ export function CompanyMembersSection() {
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('members_revoke_aria')}
onClick={() => handleRevokeInvite(inv.id)}
onClick={() => handleRevokeInvite(inv)}
disabled={revokingId === inv.id}
>
{revokingId === inv.id ? (
@@ -299,6 +340,40 @@ export function CompanyMembersSection() {
</div>
))}
{/* Shareable accept link from the latest invite: one sentence, not a
banner. A send that failed or was skipped (no mail provider) is the
page's single attn line; a sent or GoTrue-provisioned invite is a
quiet muted line with the same copy action, since the link is a
legitimate share path either way. The live region stays mounted
(empty until a link exists) so the line is announced when it
appears, not merely inserted; same reason as the roster block. */}
{canInvite && (
<div className={shareInvite ? 'px-1 pt-3' : undefined} role="status" aria-live="polite">
{shareInvite &&
(shareInvite.sent || shareInvite.provisioned ? (
<p className="text-[12.5px] leading-5 text-muted-foreground">
{t('members_invite_link_sent', { email: shareInvite.email })}{' '}
<button
type="button"
onClick={() => void handleCopyInviteLink(shareInvite.url)}
className="underline underline-offset-2 hover:text-foreground"
>
{t('members_invite_link_copy_action')}
</button>
</p>
) : (
<AttnLine
action={{
label: t('members_invite_link_copy_action'),
onClick: () => void handleCopyInviteLink(shareInvite.url),
}}
>
{t('members_invite_link_not_sent', { email: shareInvite.email })}
</AttnLine>
))}
</div>
)}
{/* Inline invite: the list's own last row instead of a separate card. */}
{canInvite && (
<form onSubmit={handleInvite} className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center">
+2
View File
@@ -198,6 +198,8 @@ RESEND_FROM_EMAIL=faktura@your-domain.com
RESEND_DELIVERY_WEBHOOK_SECRET=whsec_...
```
Invitations do not need Resend: the accept link is returned to the inviter in the app. See [SELF-HOSTING.md](./SELF-HOSTING.md#email-invoice-sending-invitations-and-reminders).
### Push Notifications
```env
+15 -3
View File
@@ -180,7 +180,9 @@ PORT=8080 docker compose up -d
- **Step 4**: Preliminary tax amount (optional, skip if unsure)
- **Step 5**: Bank details for invoices (optional)
There is no admin account or invite system: any email address can sign up. You can also use the magic link option on the login page if preferred.
There is no admin account: any email address can sign up (unless you turn public signup off, see the `AUTH_SIGNUPS_DISABLED` note under [Email](#email-invoice-sending-invitations-and-reminders)). You can also use the magic link option on the login page if preferred.
To bring in more users, invite them from **Settings > Company > Members** (company-scoped) or **Settings > Team** (consultant teams). Invitations work without a mail provider: the accept link is returned to the inviter right after the invite is created (shown under the pending list with a copy button), so it can be shared over any channel. Configuring Resend only adds automatic delivery. The link is shown once and cannot be re-sent for company invites: revoke the invitation and invite again to get a fresh link.
## Scheduled Jobs
@@ -294,7 +296,7 @@ npx tsx scripts/smoke-ai.ts ./receipt.pdf # also runs document extraction
> **Note:** `OPENAI_API_KEY` from earlier versions is not read by any code path. To use OpenAI itself, point Option 3 at `https://api.openai.com/v1`; the app has no provider-specific OpenAI integration, only the OpenAI-compatible one. Background: [#1406](https://github.com/erp-mafia/accounted/issues/1406).
### Email (Invoice Sending and Reminders)
### Email (Invoice Sending, Invitations and Reminders)
```bash
RESEND_API_KEY=re_...
@@ -303,6 +305,16 @@ RESEND_FROM_EMAIL=noreply@your-domain.com
Requires a [Resend](https://resend.com) account with a verified sender domain. Without this, invoices can still be generated as PDFs but cannot be emailed.
**Invitations do not require Resend.** When no mail provider is configured, the invite is still created and the accept link is returned to the inviter in the app (a copy button under the pending invitations list, plus a warn-level log record whose msg is `email service not configured: invite email skipped`; the Docker image logs JSON, so grep for the message text, not a `WARN` prefix; the token is never logged). Share the link manually; it is valid until the invitation expires. Resend (or plain SMTP once [#1746](https://github.com/erp-mafia/accounted/pull/1746) lands) is only needed if you want the invitation mailed automatically. There is no re-send for company invitations: revoke and invite again for a new link.
Note the two separate mail paths: this variable drives the app's own mail (invoices, invitations, reminders); account mail from GoTrue (signup confirmation, password reset, and the account-provisioning invite when `AUTH_SIGNUPS_DISABLED=true`) goes through the Supabase **Authentication > SMTP Settings** described in [Configure Authentication](#2-configure-supabase-auth) and [Troubleshooting](#troubleshooting).
```bash
AUTH_SIGNUPS_DISABLED=true
```
Set this when you have turned public signup off in GoTrue (`disable_signup`). The invite route then provisions the invitee's account through the auth admin API before writing the invitation, and GoTrue mails its own set-password link via the Supabase SMTP settings; the in-app accept link is still returned to the inviter.
### Push Notifications
```bash
@@ -548,7 +560,7 @@ portable base file alone.
-o "export_<periodId>.se"
```
- **Storage**: the included `storage-api` defaults to the local-filesystem backend. For production durability, use the `docker-compose.s3.yml` overlay and point it at S3 / MinIO.
- **SMTP**: no built-in mailer. Either set `ENABLE_EMAIL_AUTOCONFIRM=true` for dev/staging, or wire `SMTP_*` env vars in the Supabase stack to a provider (Resend, Postmark, etc.).
- **SMTP**: no built-in mailer for auth mail. Either set `ENABLE_EMAIL_AUTOCONFIRM=true` for dev/staging, or wire `SMTP_*` env vars in the Supabase stack to a provider (Resend, Postmark, etc.), the self-hosted equivalent of the **Authentication > SMTP Settings** step in [Configure Authentication](#2-configure-supabase-auth) and [Troubleshooting](#troubleshooting). If you also disable public signup in GoTrue, set `AUTH_SIGNUPS_DISABLED=true` on the app so invites provision accounts through the admin API (see [Email](#email-invoice-sending-invitations-and-reminders)). Inviting users never depends on this: the accept link is always returned in-band to the inviter.
- **Upgrades**: you sync the `supabase/postgres` image yourself; your data lives in the DB volume, so a Postgres image bump needs no migration re-run. When you pull a newer Accounted release, apply only the **new** migration files added since your last deploy (the SQL is not idempotent, so re-running already-applied migrations will error). Track which migrations you've applied, e.g. with a checksum/version table.
### Notes
+7 -1
View File
@@ -1803,7 +1803,13 @@
"members_you": "(you)",
"members_team_badge": "Team",
"members_invite_sent_title": "Invitation sent",
"members_invite_sent_dev_url": "Link logged in the console (F12)",
"members_invite_created_title": "Invitation created",
"members_invite_mail_not_sent": "The email was not sent. The invitation is still valid; share the link directly.",
"members_invite_link_sent": "The invitation was emailed to {email}. You can also share the link directly.",
"members_invite_link_not_sent": "The email to {email} was not sent. The invitation is still valid; share the link directly. The link is only shown here; to get a new one, revoke and invite again.",
"members_invite_link_copy_action": "Copy invitation link",
"members_invite_link_copied_toast": "The invitation link was copied.",
"members_invite_link_copy_failed": "The link could not be copied.",
"members_invite_sent_description": "Email sent to {email}.",
"members_invite_failed": "Could not send invitation.",
"members_removed": "Member removed",
+7 -1
View File
@@ -1803,7 +1803,13 @@
"members_you": "(du)",
"members_team_badge": "Team",
"members_invite_sent_title": "Inbjudan skickad",
"members_invite_sent_dev_url": "Länk loggad i konsolen (F12)",
"members_invite_created_title": "Inbjudan skapad",
"members_invite_mail_not_sent": "Mejlet skickades inte. Inbjudan gäller ändå, dela länken direkt.",
"members_invite_link_sent": "Inbjudan mejlades till {email}. Länken kan även delas direkt.",
"members_invite_link_not_sent": "Mejlet till {email} skickades inte. Inbjudan gäller ändå, dela länken direkt. Länken visas bara här; för en ny länk, återkalla och bjud in igen.",
"members_invite_link_copy_action": "Kopiera inbjudningslänk",
"members_invite_link_copied_toast": "Inbjudningslänken kopierades.",
"members_invite_link_copy_failed": "Länken kunde inte kopieras.",
"members_invite_sent_description": "E-post skickad till {email}.",
"members_invite_failed": "Kunde inte skicka inbjudan.",
"members_removed": "Medlem borttagen",