diff --git a/apps/api/src/handlers/account-link-help.test.ts b/apps/api/src/handlers/account-link-help.test.ts new file mode 100644 index 000000000..4a4533979 --- /dev/null +++ b/apps/api/src/handlers/account-link-help.test.ts @@ -0,0 +1,41 @@ +const { getHelpTextMock, warnMock } = vi.hoisted(() => ({ + getHelpTextMock: vi.fn(), + warnMock: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + getDeploymentAccountLinkHelpText: getHelpTextMock, +})); + +vi.mock('../logging.js', () => ({ + apiLogger: { warn: warnMock }, +})); + +import { appendAccountLinkHelpText } from './account-link-help'; + +describe('appendAccountLinkHelpText', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('appends configured deployment help', async () => { + getHelpTextMock.mockResolvedValue('Ask an admin for an invite.'); + + await expect(appendAccountLinkHelpText('Link your account.')).resolves.toBe( + 'Link your account. Ask an admin for an invite.', + ); + }); + + it('preserves the base message when help is unset or unavailable', async () => { + getHelpTextMock.mockResolvedValueOnce(null); + await expect(appendAccountLinkHelpText('Link your account.')).resolves.toBe( + 'Link your account.', + ); + + getHelpTextMock.mockRejectedValueOnce(new Error('database unavailable')); + await expect(appendAccountLinkHelpText('Link your account.')).resolves.toBe( + 'Link your account.', + ); + expect(warnMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/api/src/handlers/account-link-help.ts b/apps/api/src/handlers/account-link-help.ts new file mode 100644 index 000000000..83b7ff315 --- /dev/null +++ b/apps/api/src/handlers/account-link-help.ts @@ -0,0 +1,17 @@ +import { getDeploymentAccountLinkHelpText } from '@roomote/db/server'; + +import { apiLogger } from '../logging.js'; + +export async function appendAccountLinkHelpText( + baseMessage: string, +): Promise { + try { + const helpText = await getDeploymentAccountLinkHelpText(); + return helpText ? `${baseMessage} ${helpText}` : baseMessage; + } catch (error) { + apiLogger.warn( + `[account-link] Failed to load deployment help text: ${error instanceof Error ? error.message : String(error)}`, + ); + return baseMessage; + } +} diff --git a/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts b/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts index cee725513..7c8f41562 100644 --- a/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts +++ b/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts @@ -55,7 +55,7 @@ vi.mock('../../utils', () => ({ })); vi.mock('../../source-control-account-linking', () => ({ - buildSourceControlAccountLinkRequiredMessage: () => + buildSourceControlAccountLinkRequiredMessage: async () => 'link your Azure DevOps account', })); diff --git a/apps/api/src/handlers/ado/handleComment.ts b/apps/api/src/handlers/ado/handleComment.ts index d3377177b..27092a287 100644 --- a/apps/api/src/handlers/ado/handleComment.ts +++ b/apps/api/src/handlers/ado/handleComment.ts @@ -313,7 +313,7 @@ export async function handleAdoComment( const body = targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('ado') + ? await buildSourceControlAccountLinkRequiredMessage('ado') : buildReviewerGateMissComment(); await postMentionResponseComment({ diff --git a/apps/api/src/handlers/ado/handleWorkItemComment.ts b/apps/api/src/handlers/ado/handleWorkItemComment.ts index 4107007e4..61905e11e 100644 --- a/apps/api/src/handlers/ado/handleWorkItemComment.ts +++ b/apps/api/src/handlers/ado/handleWorkItemComment.ts @@ -454,7 +454,7 @@ export async function handleAdoWorkItemComment( await postWorkItemMentionResponseComment({ project: projectName, workItemId, - body: buildSourceControlAccountLinkRequiredMessage('ado'), + body: await buildSourceControlAccountLinkRequiredMessage('ado'), }); return { status: 'ok', message: 'account_link_required' }; diff --git a/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts b/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts index f42f951ef..1928e7c24 100644 --- a/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts +++ b/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts @@ -39,6 +39,7 @@ vi.mock('@roomote/db/server', async (importOriginal) => { return { ...actual, + getDeploymentAccountLinkHelpText: vi.fn().mockResolvedValue(null), findActiveGitHubPrReviewTask: mockFindActiveGitHubPrReviewTask, findReusableGitHubPrFollowUpOwner: mockFindReusableGitHubPrFollowUpOwner, }; diff --git a/apps/api/src/handlers/bitbucket/handleComment.ts b/apps/api/src/handlers/bitbucket/handleComment.ts index e024ab176..0cfe55b1f 100644 --- a/apps/api/src/handlers/bitbucket/handleComment.ts +++ b/apps/api/src/handlers/bitbucket/handleComment.ts @@ -261,7 +261,7 @@ export async function handleBitbucketComment( await postMentionResponseComment({ ...mentionResponseTarget, body: requiresAccountLink - ? buildSourceControlAccountLinkRequiredMessage('bitbucket') + ? await buildSourceControlAccountLinkRequiredMessage('bitbucket') : requiresEnvironment ? buildSourceControlEnvironmentRequiredMessage('bitbucket') : buildReviewerGateMissComment(), diff --git a/apps/api/src/handlers/discord/__tests__/account-link.test.ts b/apps/api/src/handlers/discord/__tests__/account-link.test.ts index 687e0086b..68cfcb600 100644 --- a/apps/api/src/handlers/discord/__tests__/account-link.test.ts +++ b/apps/api/src/handlers/discord/__tests__/account-link.test.ts @@ -3,10 +3,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const envMock = vi.hoisted(() => ({ R_APP_URL: 'https://app.example.com', })); +const appendHelpMock = vi.hoisted(() => + vi.fn(async (message: string) => message), +); vi.mock('@roomote/env', () => ({ Env: envMock, })); +vi.mock('../../account-link-help.js', () => ({ + appendAccountLinkHelpText: appendHelpMock, +})); import { buildDiscordAccountLinkFallbackInstruction, @@ -16,21 +22,37 @@ import { afterEach(() => { envMock.R_APP_URL = 'https://app.example.com'; + appendHelpMock.mockImplementation(async (message: string) => message); }); describe('Discord account-link settings copy', () => { - it('links Settings → Personal → Linked Accounts to personal settings', () => { + it('links Settings → Personal → Linked Accounts to personal settings', async () => { expect(buildDiscordAccountLinkFallbackInstruction()).toBe( 'Generate a code under [Settings → Personal → Linked Accounts](https://app.example.com/settings/personal), then DM me with `/link code:`.', ); - expect(buildDiscordLinkRequiredMessage()).toBe( + await expect(buildDiscordLinkRequiredMessage()).resolves.toBe( 'Link your Discord account to Roomote before starting tasks. Generate a code under [Settings → Personal → Linked Accounts](https://app.example.com/settings/personal), then DM me with `/link code:`.', ); - expect(buildDiscordChannelAutoStartLinkMessage('ops')).toContain( + await expect( + buildDiscordChannelAutoStartLinkMessage('ops'), + ).resolves.toContain( '[Settings → Personal → Linked Accounts](https://app.example.com/settings/personal)', ); }); + it('appends deployment help to full link prompts', async () => { + appendHelpMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); + + await expect(buildDiscordLinkRequiredMessage()).resolves.toMatch( + /Ask an admin for an invite\.$/, + ); + await expect( + buildDiscordChannelAutoStartLinkMessage('ops'), + ).resolves.toMatch(/Ask an admin for an invite\.$/); + }); + it('falls back to bold path copy when R_APP_URL is not a valid base URL', () => { envMock.R_APP_URL = 'not-a-url'; diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index f000d1984..ab1b342b7 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -54,6 +54,11 @@ const mocks = vi.hoisted(() => ({ shouldRouteUnmentioned: vi.fn(), enqueueGatewayEvent: vi.fn(), callViaEmojiConfig: vi.fn(), + appendAccountLinkHelpText: vi.fn(async (message: string) => message), +})); + +vi.mock('../../account-link-help.js', () => ({ + appendAccountLinkHelpText: mocks.appendAccountLinkHelpText, })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -225,6 +230,9 @@ async function postIngressEvent(body: unknown, secret = 'gateway-secret') { describe('Discord Gateway event handler', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.appendAccountLinkHelpText.mockImplementation( + async (message: string) => message, + ); process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; mocks.claimEvent.mockResolvedValue({ status: 'claimed', @@ -1263,6 +1271,9 @@ describe('Discord Gateway event handler', () => { }); it('sends the link DM even when the dedupe check is unavailable', async () => { + mocks.appendAccountLinkHelpText.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); mocks.findMappedUserId.mockResolvedValue(null); // Redis down: the mention flow fails open so the user is not left silent. mocks.redisSet.mockRejectedValue(new Error('redis unavailable')); @@ -1286,6 +1297,11 @@ describe('Discord Gateway event handler', () => { expect(response.status).toBe(200); expect(mocks.createDirectMessage).toHaveBeenCalledWith('discord-user-1'); + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Ask an admin for an invite.'), + }), + ); expect(mocks.reply).toHaveBeenCalledWith( expect.objectContaining({ text: 'I sent you a DM to link your Discord account.', @@ -1294,6 +1310,9 @@ describe('Discord Gateway event handler', () => { }); it('falls back to public link instructions when the account-link DM is blocked', async () => { + mocks.appendAccountLinkHelpText.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); mocks.findMappedUserId.mockResolvedValue(null); mocks.createDirectMessage.mockRejectedValue( new DiscordApiError({ @@ -1338,6 +1357,9 @@ describe('Discord Gateway event handler', () => { expect(mocks.reply.mock.calls[0]?.[0]?.text).toMatch( /\[Settings → Personal → Linked Accounts\]\([^)]+\/settings\/personal\)/, ); + expect(mocks.reply.mock.calls[0]?.[0]?.text).toContain( + 'Ask an admin for an invite.', + ); expect(mocks.startNewTask).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/handlers/discord/account-link.ts b/apps/api/src/handlers/discord/account-link.ts index e0c0dd584..6849fb043 100644 --- a/apps/api/src/handlers/discord/account-link.ts +++ b/apps/api/src/handlers/discord/account-link.ts @@ -8,6 +8,7 @@ import { Env } from '@roomote/env'; import { getRedis } from '@roomote/redis'; import { apiLogger } from '../../logging.js'; +import { appendAccountLinkHelpText } from '../account-link-help.js'; import { replyToDiscordEvent } from './replies.js'; import type { DiscordChannelContext } from './task-launch.js'; @@ -34,17 +35,21 @@ export function buildDiscordAccountLinkFallbackInstruction(): string { return `Generate a code under ${formatDiscordLinkedAccountsPath()}, then ${DISCORD_LINK_CODE_INSTRUCTION}.`; } -export function buildDiscordLinkRequiredMessage(): string { - return `Link your Discord account to Roomote before starting tasks. ${buildDiscordAccountLinkFallbackInstruction()}`; +export async function buildDiscordLinkRequiredMessage(): Promise { + return appendAccountLinkHelpText( + `Link your Discord account to Roomote before starting tasks. ${buildDiscordAccountLinkFallbackInstruction()}`, + ); } -export function buildDiscordChannelAutoStartLinkMessage( +export async function buildDiscordChannelAutoStartLinkMessage( channelName: string, -): string { - return [ - `Roomote watches **#${channelName}** and starts a task for each new message, but your Discord account is not linked to a Roomote account yet, so your message did not start one.`, - `Generate a code under ${formatDiscordLinkedAccountsPath()} in Roomote, then reply here with \`/link code:\`.`, - ].join('\n\n'); +): Promise { + return appendAccountLinkHelpText( + [ + `Roomote watches **#${channelName}** and starts a task for each new message, but your Discord account is not linked to a Roomote account yet, so your message did not start one.`, + `Generate a code under ${formatDiscordLinkedAccountsPath()} in Roomote, then reply here with \`/link code:\`.`, + ].join('\n\n'), + ); } // One link DM per user per day across every entry path (mentions, slash @@ -243,7 +248,7 @@ export async function promptDiscordAccountLink(input: { applicationId: input.applicationId, channel: input.channel, ...(input.interaction ? { interaction: input.interaction } : {}), - text: buildDiscordLinkRequiredMessage(), + text: await buildDiscordLinkRequiredMessage(), ...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {}), @@ -296,7 +301,7 @@ export async function promptDiscordAccountLink(input: { ); await input.provider.postMessage({ channelId: dmChannel.id, - text: buildDiscordLinkRequiredMessage(), + text: await buildDiscordLinkRequiredMessage(), }); dmPromptSent = true; if (slot === 'claimed') { @@ -332,7 +337,9 @@ export async function promptDiscordAccountLink(input: { text: buildAccountLinkThreadReplyText({ dmPromptSent, accountLabel: DISCORD_ACCOUNT_LABEL, - fallbackInstruction: buildDiscordAccountLinkFallbackInstruction(), + fallbackInstruction: await appendAccountLinkHelpText( + buildDiscordAccountLinkFallbackInstruction(), + ), }), ...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index 2cff01b55..e30e8b15c 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -81,7 +81,7 @@ async function sendLinkNudgeBestEffort(input: { ); await input.provider.postMessage({ channelId: dmChannel.id, - text: buildDiscordChannelAutoStartLinkMessage(input.channelName), + text: await buildDiscordChannelAutoStartLinkMessage(input.channelName), }); await markAccountLinkDmSent(input.discordUserId); } catch (error) { diff --git a/apps/api/src/handlers/gitea/handleComment.ts b/apps/api/src/handlers/gitea/handleComment.ts index cfda8d320..fc8ce9def 100644 --- a/apps/api/src/handlers/gitea/handleComment.ts +++ b/apps/api/src/handlers/gitea/handleComment.ts @@ -369,7 +369,7 @@ async function handleGiteaIssueComment({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitea') + ? await buildSourceControlAccountLinkRequiredMessage('gitea') : buildIssueGateMissComment(), }); @@ -465,7 +465,7 @@ async function handleGiteaPullRequestComment({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitea') + ? await buildSourceControlAccountLinkRequiredMessage('gitea') : targetsResult.status === 'error' && targetsResult.message.includes('no environment mapping') ? buildSourceControlEnvironmentRequiredMessage('gitea') diff --git a/apps/api/src/handlers/github/handleGitHubIssueComment.ts b/apps/api/src/handlers/github/handleGitHubIssueComment.ts index 4a526ebd9..abed85400 100644 --- a/apps/api/src/handlers/github/handleGitHubIssueComment.ts +++ b/apps/api/src/handlers/github/handleGitHubIssueComment.ts @@ -180,7 +180,7 @@ export async function handleGitHubIssueComment( ...replyTarget, body: commenterGate.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('github') + ? await buildSourceControlAccountLinkRequiredMessage('github') : buildGateMissComment(), }); @@ -198,7 +198,7 @@ export async function handleGitHubIssueComment( if (!target?.properties.userId) { await postIssueComment({ ...replyTarget, - body: buildSourceControlAccountLinkRequiredMessage('github'), + body: await buildSourceControlAccountLinkRequiredMessage('github'), }); return { status: 'ok', message: 'account_link_required' }; diff --git a/apps/api/src/handlers/github/handlePrComment.ts b/apps/api/src/handlers/github/handlePrComment.ts index 91e2f201a..18071def7 100644 --- a/apps/api/src/handlers/github/handlePrComment.ts +++ b/apps/api/src/handlers/github/handlePrComment.ts @@ -1195,7 +1195,7 @@ export async function handlePrComment( target: mentionResponseTarget, body: reviewerGate.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('github') + ? await buildSourceControlAccountLinkRequiredMessage('github') : buildReviewerGateMissComment(), }); diff --git a/apps/api/src/handlers/gitlab/handleNote.ts b/apps/api/src/handlers/gitlab/handleNote.ts index fdb737e11..55deb8075 100644 --- a/apps/api/src/handlers/gitlab/handleNote.ts +++ b/apps/api/src/handlers/gitlab/handleNote.ts @@ -293,7 +293,7 @@ async function handleGitLabIssueNote({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitlab') + ? await buildSourceControlAccountLinkRequiredMessage('gitlab') : buildIssueGateMissNote(), }); @@ -383,7 +383,7 @@ async function handleGitLabMergeRequestNote({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitlab') + ? await buildSourceControlAccountLinkRequiredMessage('gitlab') : buildReviewerGateMissNote(), }); diff --git a/apps/api/src/handlers/source-control-account-linking.test.ts b/apps/api/src/handlers/source-control-account-linking.test.ts new file mode 100644 index 000000000..d90e79d02 --- /dev/null +++ b/apps/api/src/handlers/source-control-account-linking.test.ts @@ -0,0 +1,32 @@ +const { appendHelpMock, envMock } = vi.hoisted(() => ({ + appendHelpMock: vi.fn(async (message: string) => `${message} Custom help.`), + envMock: { R_APP_URL: 'https://app.example.com' }, +})); + +vi.mock('@roomote/env', () => ({ Env: envMock })); +vi.mock('./account-link-help.js', () => ({ + appendAccountLinkHelpText: appendHelpMock, +})); + +import { buildSourceControlAccountLinkRequiredMessage } from './source-control-account-linking'; + +describe('buildSourceControlAccountLinkRequiredMessage', () => { + it('keeps provider copy and appends deployment help', async () => { + await expect( + buildSourceControlAccountLinkRequiredMessage('github'), + ).resolves.toContain( + '[Settings -> Linked Accounts](https://app.example.com/settings?service=github)', + ); + await expect( + buildSourceControlAccountLinkRequiredMessage('github'), + ).resolves.toMatch(/mention me again\. Custom help\.$/); + }); + + it('keeps provider setup guidance before deployment help', async () => { + await expect( + buildSourceControlAccountLinkRequiredMessage('gitlab'), + ).resolves.toMatch( + /add the GitLab OAuth client credentials.*first\. Custom help\.$/, + ); + }); +}); diff --git a/apps/api/src/handlers/source-control-account-linking.ts b/apps/api/src/handlers/source-control-account-linking.ts index 77647dbd7..a5d622cee 100644 --- a/apps/api/src/handlers/source-control-account-linking.ts +++ b/apps/api/src/handlers/source-control-account-linking.ts @@ -1,6 +1,8 @@ import { Env } from '@roomote/env'; import { PRODUCT_NAME } from '@roomote/types'; +import { appendAccountLinkHelpText } from './account-link-help.js'; + type SourceControlCommentProvider = | 'github' | 'gitlab' @@ -78,9 +80,9 @@ export function buildSourceControlEnvironmentRequiredMessage( return `I saw the mention, but no Roomote environment is mapped to this ${copy.accountLabel} repository. Set up an environment and map this repository from ${settingsText}, then mention me again.`; } -export function buildSourceControlAccountLinkRequiredMessage( +export async function buildSourceControlAccountLinkRequiredMessage( provider: SourceControlCommentProvider, -): string { +): Promise { const copy = sourceControlCommentProviderCopy[provider]; const settingsUrl = getLinkedAccountsSettingsUrl(provider); @@ -95,8 +97,12 @@ export function buildSourceControlAccountLinkRequiredMessage( provider === 'bitbucket' || provider === 'ado' ) { - return `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction} If ${copy.accountLabel} is missing from Linked Accounts, ask an admin to add the ${copy.accountLabel} OAuth client credentials in Settings -> Environments -> Source Control first.`; + return appendAccountLinkHelpText( + `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction} If ${copy.accountLabel} is missing from Linked Accounts, ask an admin to add the ${copy.accountLabel} OAuth client credentials in Settings -> Environments -> Source Control first.`, + ); } - return `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction}`; + return appendAccountLinkHelpText( + `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction}`, + ); } diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 5aae6c763..5c33fb1fa 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -39,6 +39,7 @@ const { updateReturningMock, usersFindFirstMock, telegramMappingsFindFirstMock, + appendAccountLinkHelpTextMock, } = vi.hoisted(() => ({ addReactionMock: vi.fn(), answerCallbackQueryMock: vi.fn(), @@ -82,12 +83,17 @@ const { updateReturningMock: vi.fn(), usersFindFirstMock: vi.fn(), telegramMappingsFindFirstMock: vi.fn(), + appendAccountLinkHelpTextMock: vi.fn(async (message: string) => message), })); vi.mock('@roomote/env', () => ({ Env: envMock, })); +vi.mock('../../account-link-help.js', () => ({ + appendAccountLinkHelpText: appendAccountLinkHelpTextMock, +})); + vi.mock('@roomote/redis', () => ({ getRedis: vi.fn(() => ({ set: redisSetMock, @@ -359,6 +365,9 @@ function mockTelegramLinkedSender(userId = 'launch-owner-1') { describe('Telegram webhook handler', () => { beforeEach(() => { vi.clearAllMocks(); + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => message, + ); // Some tests queue one-shot lookup results on these mocks. Reset them so // later tests do not inherit stale values when the whole suite runs. @@ -484,6 +493,9 @@ describe('Telegram webhook handler', () => { }); it('nudges an unlinked sender to link and drops the message', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate(createTelegramUpdate()); await expect(response.json()).resolves.toEqual({ @@ -509,9 +521,15 @@ describe('Telegram webhook handler', () => { textFormat: 'markdown', }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); }); it('nudges an unlinked group sender who addressed the bot with a deep-link button', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate( createTelegramUpdate({ message: { @@ -521,6 +539,9 @@ describe('Telegram webhook handler', () => { }, }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); await expect(response.json()).resolves.toEqual({ ok: true, @@ -636,6 +657,9 @@ describe('Telegram webhook handler', () => { }); it('replies with linking instructions to the /start link deep link', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate( createTelegramUpdate({ message: { @@ -644,6 +668,9 @@ describe('Telegram webhook handler', () => { }, }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); await expect(response.json()).resolves.toEqual({ ok: true, @@ -1386,6 +1413,9 @@ describe('Telegram webhook handler', () => { }); it('welcomes bare /start commands from an unlinked sender', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate( createTelegramUpdate({ message: { @@ -1414,6 +1444,9 @@ describe('Telegram webhook handler', () => { textFormat: 'markdown', }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); }); it('nudges unlinked senders to link their account in the /start welcome', async () => { diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 7ca9813a0..0c5d6d668 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -81,6 +81,7 @@ import { rememberTelegramImplicitTopic, verifyTelegramWebhookSecret, } from './webhook-gate.js'; +import { appendAccountLinkHelpText } from '../account-link-help.js'; // Deep-link payload used by the group "link account" button: tapping // https://t.me/?start=link opens the bot's DM with "/start link". @@ -248,7 +249,9 @@ telegram.post('/', async (c) => { chatId: String(message.chat.id), text: senderUserId ? '✅ This Telegram account is already linked to your Roomote account — head back to the group and send your request again.' - : 'Let’s link your Telegram account: generate a code under *Settings → Personal → Linked Accounts* in Roomote, then send it here.', + : await appendAccountLinkHelpText( + 'Let’s link your Telegram account: generate a code under *Settings → Personal → Linked Accounts* in Roomote, then send it here.', + ), textFormat: 'markdown', }); @@ -270,7 +273,7 @@ telegram.post('/', async (c) => { chatId: String(message.chat.id), text: senderUserId ? TELEGRAM_WELCOME_MESSAGE - : `${TELEGRAM_WELCOME_MESSAGE}\n\n${TELEGRAM_WELCOME_LINK_NUDGE}`, + : `${TELEGRAM_WELCOME_MESSAGE}\n\n${await appendAccountLinkHelpText(TELEGRAM_WELCOME_LINK_NUDGE)}`, textFormat: 'markdown', }); @@ -287,7 +290,7 @@ telegram.post('/', async (c) => { if (isTelegramPrivateChat(message)) { await postTelegramMessageBestEffort({ chatId: String(message.chat.id), - text: TELEGRAM_LINK_REQUIRED_MESSAGE, + text: await appendAccountLinkHelpText(TELEGRAM_LINK_REQUIRED_MESSAGE), textFormat: 'markdown', }); } else { @@ -314,7 +317,10 @@ telegram.post('/', async (c) => { replyToMessageId: nudgeMetadata.communicationMessageId, ...(botUsername ? { - text: 'Link your Telegram account to Roomote first, then send your request again.', + text: await appendAccountLinkHelpText( + 'Link your Telegram account to Roomote first, then send your request again.', + ), + textFormat: 'markdown' as const, buttons: [ [ { @@ -325,7 +331,9 @@ telegram.post('/', async (c) => { ], } : { - text: TELEGRAM_LINK_REQUIRED_MESSAGE, + text: await appendAccountLinkHelpText( + TELEGRAM_LINK_REQUIRED_MESSAGE, + ), textFormat: 'markdown' as const, }), }); diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index f8a87af5b..64d4323e9 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -112,6 +112,20 @@ it before leaving the page. You can revoke an invite before it is used. Revoking an invite does not affect people who already joined with it. +## Customize account linking help + +Admins can add deployment-specific guidance under **Settings > Users > Account +linking help**. Roomote appends this text when an unlinked user tries to start +work from a source-control comment, Discord, or Telegram. + +Use it to explain how someone can request an invite or whom to contact. Markdown +links are supported, but plain text with a full URL works across every supported +surface. Leave the field blank to use Roomote's built-in account linking message +without extra guidance. + +Slack and Microsoft Teams prompts do not use this setting because users enter +through their configured workspace or tenant rather than an invite. + ## Manage existing users The user list shows active users, their email address, join date, and current diff --git a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx index 596e617f9..110ab4428 100644 --- a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx +++ b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx @@ -235,7 +235,9 @@ describe('AuthForm', () => { }); it('hides account creation and points at an admin without an invite', () => { - render(); + render( + , + ); fireEvent.click( screen.getByRole('button', { name: 'Continue with email' }), @@ -249,6 +251,7 @@ describe('AuthForm', () => { screen.getByText(/Need an account\? Forgot your password\?/), ).toBeVisible(); expect(screen.getByText(/Ask your admin\./)).toBeVisible(); + expect(screen.getByRole('button', { name: 'Talk to us' })).toBeVisible(); }); it('can hide the account and password help copy for bootstrap sign-up', () => { diff --git a/apps/web/src/app/(unauthenticated)/auth-form.tsx b/apps/web/src/app/(unauthenticated)/auth-form.tsx index dea256096..bc9273357 100644 --- a/apps/web/src/app/(unauthenticated)/auth-form.tsx +++ b/apps/web/src/app/(unauthenticated)/auth-form.tsx @@ -70,6 +70,7 @@ export function AuthForm({ inviteRole = null, hideModeSwitchMessage = false, noticeMessage = null, + accountLinkHelpText = null, }: { enabledProviders?: AuthProvider[]; /** @@ -88,6 +89,7 @@ export function AuthForm({ * the per-attempt error state. */ noticeMessage?: string | null; + accountLinkHelpText?: string | null; }) { const router = useRouter(); const searchParams = useSearchParams(); @@ -219,6 +221,7 @@ export function AuthForm({
( @@ -194,11 +197,18 @@ export function EmailPasswordAuth({ )} {hideModeSwitchMessage ? null : ( -

- Need an account? Forgot your password? -
- Ask your admin. -

+
+

+ Need an account? Forgot your password? +
+ Ask your admin. +

+ {accountLinkHelpText ? ( + + {accountLinkHelpText} + + ) : null} +
)} ); diff --git a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx index 990f99d22..a6ed5bcc3 100644 --- a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx +++ b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx @@ -11,12 +11,14 @@ export function SignInPageClient({ inviteRole = null, inviteInvalid = false, seatLimitBlocked = false, + accountLinkHelpText = null, }: { enabledProviders: AuthProvider[]; canSignUp: boolean; inviteRole?: UserRole | null; inviteInvalid?: boolean; seatLimitBlocked?: boolean; + accountLinkHelpText?: string | null; }) { useSetAuthState(); @@ -25,6 +27,7 @@ export function SignInPageClient({ enabledProviders={enabledProviders} canSignUp={canSignUp} inviteRole={inviteRole} + accountLinkHelpText={accountLinkHelpText} noticeMessage={ seatLimitBlocked ? 'This deployment has reached its licensed user limit. Ask an admin to free a seat or add a license key, then sign in again.' diff --git a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx index 06f390db8..27d010b0a 100644 --- a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { getDeploymentAccountLinkHelpText } from '@roomote/db/server'; import { canVisitorSignUp, @@ -41,14 +42,18 @@ export default async function Page(props: { // Whether the visitor arrived with a usable invite (the /invite/ // route stores it in the invite cookie) or bootstrap rights; without one, // the form offers sign-in only and account creation stays hidden. - const canSignUp = await canVisitorSignUp(); - const invite = await getRequestInviteSummary(); - const searchParams = await props.searchParams; + const [canSignUp, invite, searchParams, authContext, accountLinkHelpText] = + await Promise.all([ + canVisitorSignUp(), + getRequestInviteSummary(), + props.searchParams, + getSignedInAuthContext(), + getDeploymentAccountLinkHelpText(), + ]); // A visitor bounced here by the seat gate still holds their Better Auth // session cookie, so re-running the auth evaluation identifies them and // lets the form explain the rejection instead of silently offering // sign-in again. - const authContext = await getSignedInAuthContext(); const seatLimitBlocked = !authContext.success && authContext.reason === 'seat_limit'; @@ -59,6 +64,7 @@ export default async function Page(props: { inviteRole={invite?.role ?? null} inviteInvalid={hasInvitedParam(searchParams.invited) && invite === null} seatLimitBlocked={seatLimitBlocked} + accountLinkHelpText={accountLinkHelpText} /> ); } diff --git a/apps/web/src/components/settings/AccountLinkHelpSection.tsx b/apps/web/src/components/settings/AccountLinkHelpSection.tsx new file mode 100644 index 000000000..4844c82aa --- /dev/null +++ b/apps/web/src/components/settings/AccountLinkHelpSection.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { Section } from '@/components/settings'; +import { + AlertCircle, + Button, + Label, + LucideLink, + Skeleton, + Textarea, +} from '@/components/system'; +import { useTRPC } from '@/trpc/client'; + +export function AccountLinkHelpSection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const queryKey = trpc.accessPolicy.accountLinkHelp.queryKey(); + const settingsQuery = useQuery( + trpc.accessPolicy.accountLinkHelp.queryOptions(), + ); + const [value, setValue] = useState(''); + const [savedValue, setSavedValue] = useState(''); + const isDirty = value !== savedValue; + + const updateMutation = useMutation( + trpc.accessPolicy.setAccountLinkHelp.mutationOptions({ + onSuccess: (result) => { + queryClient.setQueryData(queryKey, result); + const nextValue = result.helpText ?? ''; + setValue(nextValue); + setSavedValue(nextValue); + toast.success('Account linking help saved.'); + }, + onError: (error) => toast.error(error.message), + onSettled: () => + queryClient.invalidateQueries({ + queryKey, + }), + }), + ); + + const serverValue = settingsQuery.data?.helpText ?? ''; + + useEffect(() => { + if (settingsQuery.data && !isDirty) { + setValue(serverValue); + setSavedValue(serverValue); + } + }, [isDirty, serverValue, settingsQuery.data]); + + const footer = + !isDirty && !updateMutation.isPending ? undefined : ( + <> + + + + ); + + return ( +
+

+ Add deployment-specific help when Roomote asks someone to link an + account before starting work, such as how to request an invite. This + appears in source-control comments and Discord and Telegram prompts. +

+ {settingsQuery.isPending ? ( + + ) : settingsQuery.isError ? ( +
+ +

Failed to load account linking help.

+
+ ) : ( +
+ +