diff --git a/client/src/api/assistant.ts b/client/src/api/assistant.ts index b00af85e1..e44534315 100644 --- a/client/src/api/assistant.ts +++ b/client/src/api/assistant.ts @@ -155,7 +155,10 @@ export const aiApi = { ackPrivacy: (body: AiPrivacyAckRequest): Promise => apiClient.post('/ai/privacy-ack', body).then((r) => r.data), - chatgptStart: (): Promise => apiClient.post('/ai/connect/chatgpt/start', {}).then((r) => r.data), + chatgptStart: (scope: AiConnectionScope = 'user'): Promise => + apiClient + .post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {}) + .then((r) => r.data), chatgptFinish: (body: AiChatgptFinishRequest): Promise => apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data), chatgptSignOut: (): Promise => diff --git a/client/src/components/Settings/ai/AiConnectPanel.tsx b/client/src/components/Settings/ai/AiConnectPanel.tsx index 898098518..cc30339ad 100644 --- a/client/src/components/Settings/ai/AiConnectPanel.tsx +++ b/client/src/components/Settings/ai/AiConnectPanel.tsx @@ -349,10 +349,14 @@ function PrivacyNote({ provider }: { provider: string }) { ); } -/** The two ways each scope offers: ChatGPT is never an instance's, an own server never a user's (#1772). */ +/** + * The ways each scope offers. An own server is never a user's (#1772). An + * admin may share their own ChatGPT plan with everyone: whether their plan and + * OpenAI's terms allow that is theirs to judge, and the form says so. + */ const SCOPE_MODES: Record = { user: ['chatgpt', 'key'], - instance: ['key', 'custom'], + instance: ['key', 'chatgpt', 'custom'], }; const MODE_TILES: Record< @@ -392,7 +396,7 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil const first = initialMode ?? prefillModeOf(prefill); return first && SCOPE_MODES[scope].includes(first) ? first : null; }); - const chatgpt = useChatgptConnect({ active: scope === 'user' && mode === 'chatgpt', onConnected }); + const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope }); const keyConnect = useKeyConnect({ scope, prefill, onConnected }); const custom = useCustomConnect({ prefill, onConnected }); const mcpEnabled = useAddonStore((s) => s.isEnabled('mcp')); @@ -431,6 +435,16 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil {scope === 'instance' && prefill && {t('assistant.admin.prefilled')}} + {mode === 'chatgpt' && scope === 'instance' && ( + // Sharing one person's plan with everyone is the admin's call to make; TREK only says what it means. +

+ {t('assistant.admin.chatgptShared')} +

+ )} {mode === 'chatgpt' && setMode('key')} />} {mode === 'key' && } {mode === 'custom' && } diff --git a/client/src/components/Settings/ai/useAiConnect.ts b/client/src/components/Settings/ai/useAiConnect.ts index f5370c246..676dfcabb 100644 --- a/client/src/components/Settings/ai/useAiConnect.ts +++ b/client/src/components/Settings/ai/useAiConnect.ts @@ -157,7 +157,16 @@ export function useCustomConnect({ * page; pasting that address back finishes the sign-in at once, and coming * back to this tab puts the cursor in the field. */ -export function useChatgptConnect({ active, onConnected }: { active: boolean; onConnected: () => void }) { +export function useChatgptConnect({ + active, + onConnected, + scope = 'user', +}: { + active: boolean; + onConnected: () => void; + /** The admin's sign-in shares their plan with everyone here. */ + scope?: AiConnectionScope; +}) { const { t } = useTranslation(); const toast = useToast(); const [attempt, setAttempt] = useState(0); @@ -171,7 +180,7 @@ export function useChatgptConnect({ active, onConnected }: { active: boolean; on useEffect(() => { if (!active) return; let cancelled = false; - void aiConnectionsRepo.chatgptStart().then( + void aiConnectionsRepo.chatgptStart(scope).then( (next) => { if (!cancelled) setFlow(next); }, @@ -182,35 +191,7 @@ export function useChatgptConnect({ active, onConnected }: { active: boolean; on return () => { cancelled = true; }; - }, [active, attempt]); - - // Back from the sign-in window: the address the user just copied is taken - // off the clipboard where the browser allows it, else the field has the cursor. - const takeCopied = useEffectEvent(() => { - if (pasted.trim() || busy) return; - if (!navigator.clipboard?.readText || !window.isSecureContext) { - inputRef.current?.focus(); - return; - } - navigator.clipboard.readText().then( - (text) => { - if (!paste(text)) inputRef.current?.focus(); - }, - () => inputRef.current?.focus() - ); - }); - useEffect(() => { - if (!opened) return; - const onBack = () => { - if (document.visibilityState === 'visible') takeCopied(); - }; - document.addEventListener('visibilitychange', onBack); - window.addEventListener('focus', onBack); - return () => { - document.removeEventListener('visibilitychange', onBack); - window.removeEventListener('focus', onBack); - }; - }, [opened]); + }, [active, attempt, scope]); /** A fresh sign-in: the old one ran out or was used up. */ const restart = () => { @@ -274,6 +255,34 @@ export function useChatgptConnect({ active, onConnected }: { active: boolean; on ); }; + // Back from the sign-in window: the address the user just copied is taken + // off the clipboard where the browser allows it, else the field has the cursor. + const takeCopied = useEffectEvent(() => { + if (pasted.trim() || busy) return; + if (!navigator.clipboard?.readText || !window.isSecureContext) { + inputRef.current?.focus(); + return; + } + navigator.clipboard.readText().then( + (text) => { + if (!paste(text)) inputRef.current?.focus(); + }, + () => inputRef.current?.focus() + ); + }); + useEffect(() => { + if (!opened) return; + const onBack = () => { + if (document.visibilityState === 'visible') takeCopied(); + }; + document.addEventListener('visibilitychange', onBack); + window.addEventListener('focus', onBack); + return () => { + document.removeEventListener('visibilitychange', onBack); + window.removeEventListener('focus', onBack); + }; + }, [opened]); + return { flow, opened, diff --git a/client/src/repo/aiConnectionsRepo.ts b/client/src/repo/aiConnectionsRepo.ts index 62fa65f2f..a3f410d05 100644 --- a/client/src/repo/aiConnectionsRepo.ts +++ b/client/src/repo/aiConnectionsRepo.ts @@ -58,9 +58,9 @@ export const aiConnectionsRepo = { await aiApi.ackPrivacy(body); }, - async chatgptStart() { + async chatgptStart(scope: AiConnectionScope = 'user') { requireOnline(); - return aiAuthStartResponseSchema.parse(await aiApi.chatgptStart()); + return aiAuthStartResponseSchema.parse(await aiApi.chatgptStart(scope)); }, async chatgptFinish(body: AiChatgptFinishRequest) { diff --git a/server/src/nest/ai-connections/ai-admin.controller.ts b/server/src/nest/ai-connections/ai-admin.controller.ts index 40c76ed6f..83433af16 100644 --- a/server/src/nest/ai-connections/ai-admin.controller.ts +++ b/server/src/nest/ai-connections/ai-admin.controller.ts @@ -9,6 +9,7 @@ import { ManagedForbidden } from '../common/managed'; import { ResponseContract } from '../common/response-contract'; import { AiAdminSettingsDto, AiInstanceConnectionDto } from './ai-connections.dto'; import { AiConnectionsService } from './ai-connections.service'; +import { ChatgptAuthService } from './chatgpt-auth.service'; import { OpenrouterAuthService } from './openrouter-auth.service'; import { Body, Controller, Delete, Get, HttpCode, Post, Put, UseGuards } from '@nestjs/common'; import { @@ -34,6 +35,7 @@ export class AiAdminController { constructor( private readonly connections: AiConnectionsService, private readonly openrouter: OpenrouterAuthService, + private readonly chatgpt: ChatgptAuthService, ) {} @Get('settings') @@ -81,4 +83,20 @@ export class AiAdminController { startOpenrouter(@CurrentUser() user: User) { return this.openrouter.start(user.id, 'instance'); } + + /** The admin shares their own ChatGPT plan with everyone here; the sign-in finishes on the user route. */ + @Post('connect/chatgpt/start') + @HttpCode(200) + @ResponseContract(aiAuthStartResponseSchema) + startChatgpt(@CurrentUser() user: User) { + return this.chatgpt.start(user.id, 'instance'); + } + + @Post('connect/chatgpt/sign-out') + @HttpCode(200) + @ResponseContract(aiAdminConnectionResponseSchema) + async signOutChatgpt(@CurrentUser() user: User) { + await this.chatgpt.signOut(user.id, 'instance'); + return this.connections.instanceConnection(); + } } diff --git a/server/src/nest/ai-connections/ai-auth-flow.store.ts b/server/src/nest/ai-connections/ai-auth-flow.store.ts index 7bc9d9791..9b27441ff 100644 --- a/server/src/nest/ai-connections/ai-auth-flow.store.ts +++ b/server/src/nest/ai-connections/ai-auth-flow.store.ts @@ -7,6 +7,8 @@ export const AI_AUTH_FLOW_TTL_MS = 10 * 60_000; export interface ChatgptAuthFlow { provider: 'chatgpt'; userId: number; + /** A user's own plan, or the plan an admin shares with everyone on the instance. */ + scope: AiConnectionScope; verifier: string; state: string; nonce: string; diff --git a/server/src/nest/ai-connections/ai-connections.controller.ts b/server/src/nest/ai-connections/ai-connections.controller.ts index 70216814f..b411671ee 100644 --- a/server/src/nest/ai-connections/ai-connections.controller.ts +++ b/server/src/nest/ai-connections/ai-connections.controller.ts @@ -96,8 +96,9 @@ export class AiConnectionsController { @HttpCode(200) @ResponseContract(aiConnectionViewSchema) async finishChatgpt(@CurrentUser() user: User, @Body() body: AiChatgptFinishDto) { - await this.chatgpt.finish(user.id, body.flowId, body.pastedUrl); - return this.connections.ownView(user.id); + // A sign-in an admin started for everyone answers with the instance's connection. + const scope = await this.chatgpt.finish(user, body.flowId, body.pastedUrl); + return scope === 'instance' ? this.connections.instanceView() : this.connections.ownView(user.id); } @Post('connect/chatgpt/sign-out') diff --git a/server/src/nest/ai-connections/ai-connections.service.ts b/server/src/nest/ai-connections/ai-connections.service.ts index 7cdb241ba..b91413e22 100644 --- a/server/src/nest/ai-connections/ai-connections.service.ts +++ b/server/src/nest/ai-connections/ai-connections.service.ts @@ -243,6 +243,13 @@ export class AiConnectionsService implements SharedAiConnectionPort { return saved; } + /** The instance's connection as a user's own is shown, once a sign-in saved it. */ + async instanceView(): Promise { + const row = await this.connections.findInstance(); + if (!row) throw aiRefusal('not_found', 'No connection for everyone yet'); + return viewOf(row); + } + async instanceConnection(): Promise { const row = await this.connections.findInstance(); return { diff --git a/server/src/nest/ai-connections/chatgpt-auth.service.ts b/server/src/nest/ai-connections/chatgpt-auth.service.ts index 090d7da12..c6d719f55 100644 --- a/server/src/nest/ai-connections/chatgpt-auth.service.ts +++ b/server/src/nest/ai-connections/chatgpt-auth.service.ts @@ -15,7 +15,7 @@ import { ModelCatalogService, pickDefaultModel } from './model-catalog.service'; import { AiProviderError, errorCodeOf } from './providers/provider-errors'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable } from '@nestjs/common'; -import type { AiAuthStartResponse, AiModelInfo } from '@trek/shared'; +import type { AiAuthStartResponse, AiConnectionScope, AiModelInfo } from '@trek/shared'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { z } from 'zod'; @@ -150,9 +150,15 @@ export class ChatgptAuthService { private readonly lock: AiRefreshLock, ) {} - async start(userId: number): Promise { - await this.settings.assertCanConnectOwn(userId); - const existing = await this.connections.findForUser(userId); + /** + * Starts a sign-in for the user's own connection, or for the instance's: + * an admin may share their plan with everyone here. Whether that suits + * their plan and OpenAI's terms is the admin's call; the setting says so. + */ + async start(userId: number, scope: AiConnectionScope = 'user'): Promise { + if (scope === 'user') await this.settings.assertCanConnectOwn(userId); + const existing = + scope === 'instance' ? await this.connections.findInstance() : await this.connections.findForUser(userId); const savedClientId = existing?.kind === 'chatgpt' ? (this.accountOf(existing).clientId ?? null) : null; const verifier = randomToken(); const state = randomToken(); @@ -173,7 +179,16 @@ export class ChatgptAuthService { }); const flowId = randomUUID(); const expiresAt = Date.now() + AI_AUTH_FLOW_TTL_MS; - await this.flows.put(flowId, { provider: 'chatgpt', userId, verifier, state, nonce, savedClientId, expiresAt }); + await this.flows.put(flowId, { + provider: 'chatgpt', + userId, + scope, + verifier, + state, + nonce, + savedClientId, + expiresAt, + }); return { flowId, authorizeUrl: `${AUTHORIZE_URL}?${params.toString()}`, @@ -183,16 +198,18 @@ export class ChatgptAuthService { /** * Finishes a sign-in from the pasted callback address and saves the - * connection. The address is checked before the flow is taken, so a wrong - * paste can be corrected without starting over. + * connection, the user's or (started by an admin) the instance's; answers + * which. The address is checked before the flow is taken, so a wrong paste + * can be corrected without starting over. */ - async finish(userId: number, flowId: string, pastedUrl: string): Promise { - await this.settings.assertCanConnectOwn(userId); + async finish(user: { id: number; role: string }, flowId: string, pastedUrl: string): Promise { const callback = readPastedCallback(pastedUrl); - const flow = await this.flows.take(flowId, userId, Date.now()); + const flow = await this.flows.take(flowId, user.id, Date.now()); if (flow?.provider !== 'chatgpt' || callback.state !== flow.state) { throw aiRefusal('expired', 'The sign-in took too long; start it again'); } + if (flow.scope === 'instance' && user.role !== 'admin') throw aiRefusal('forbidden', 'Admin access required'); + if (flow.scope === 'user') await this.settings.assertCanConnectOwn(user.id); if (callback.error) throw aiRefusal('generic', 'ChatGPT did not complete the sign-in'); // A first sign-in names the client id issued to this user's registration; // a later one may leave it out, but must not name another. @@ -214,7 +231,8 @@ export class ChatgptAuthService { throw aiRefusal('generic', 'The sign-in could not be verified'); } - const existing = await this.connections.findForUser(userId); + const existing = + flow.scope === 'instance' ? await this.connections.findInstance() : await this.connections.findForUser(user.id); const previous: AiConnectionAccount = existing?.kind === 'chatgpt' ? this.accountOf(existing) : {}; if (previous.sub && previous.sub !== claims.sub) { throw aiRefusal('account_mismatch', 'This is another ChatGPT account; remove the connection first'); @@ -225,7 +243,7 @@ export class ChatgptAuthService { } const model = await this.modelFor(existing, tokens.access_token); - await this.connections.save(userId, { + await this.connections.save(flow.scope === 'instance' ? null : user.id, { kind: 'chatgpt', model, baseUrl: null, @@ -237,14 +255,16 @@ export class ChatgptAuthService { hint: typeof claims.email === 'string' ? claims.email : null, account: encodeJson(AI_CONNECTION_ACCOUNT, { clientId, sub: claims.sub, scopes }), }); + return flow.scope; } /** * Signs the user out: the tokens go, the registration stays (the client id * and the account), so signing in again is the same registration. */ - async signOut(userId: number): Promise { - const row = await this.connections.findForUser(userId); + async signOut(userId: number, scope: AiConnectionScope = 'user'): Promise { + const row = + scope === 'instance' ? await this.connections.findInstance() : await this.connections.findForUser(userId); if (row?.kind !== 'chatgpt') throw aiRefusal('not_found', 'No ChatGPT sign-in to sign out of'); await this.connections.clearSecrets(row.id, 'signed_out'); } diff --git a/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts b/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts index e2bcd5645..e2202ad33 100644 --- a/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts +++ b/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts @@ -50,6 +50,7 @@ describe('InMemoryAiAuthFlowStore', () => { const flow = (userId: number, expiresAt: number): ChatgptAuthFlow => ({ provider: 'chatgpt', userId, + scope: 'user', verifier: 'v', state: 's', nonce: 'n', diff --git a/server/tests/unit/nest/ai-connections/chatgpt-auth.service.test.ts b/server/tests/unit/nest/ai-connections/chatgpt-auth.service.test.ts index dce891c10..290986e76 100644 --- a/server/tests/unit/nest/ai-connections/chatgpt-auth.service.test.ts +++ b/server/tests/unit/nest/ai-connections/chatgpt-auth.service.test.ts @@ -24,7 +24,7 @@ import { OidcService } from '../../../../src/nest/oidc/oidc.service'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { findRow } from '../../../helpers/factories/rows'; import { readAppSetting } from '../../../helpers/factories/settings'; -import { makeUser } from '../../../helpers/factories/users'; +import { makeAdmin, makeUser } from '../../../helpers/factories/users'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestModule, type TestModule } from '../../../helpers/test-module'; import { MikroORM } from '@mikro-orm/core'; @@ -100,8 +100,8 @@ async function refusal(promise: Promise) { } /** Starts a sign-in and answers its flow id with the state and nonce the authorize URL carried. */ -async function started(userId: number) { - const start = await service().start(userId); +async function started(userId: number, scope: 'user' | 'instance' = 'user') { + const start = await service().start(userId, scope); const query = new URL(start.authorizeUrl).searchParams; return { flowId: start.flowId, state: query.get('state') ?? '', nonce: query.get('nonce') ?? '', query }; } @@ -206,12 +206,35 @@ describe('finishing a sign-in', () => { expect(openSecrets(row?.secrets)).toMatchObject({ accessToken: access, refreshToken: 'refresh-1' }); }); + it('CGPT-030: an admin shares their plan with everyone; only an admin finishes that sign-in', async () => { + const { user: admin } = await makeAdmin(orm()); + const { user: member } = await makeUser(orm()); + const shared = await started(admin.id, 'instance'); + const url = callbackUrl({ code: 'c', state: shared.state, client_id: 'oaiapp_shared' }); + // A flow belongs to whoever started it, so a member cannot even take it. + expect((await refusal(service().finish(member, shared.flowId, url))).body.code).toBe('expired'); + + safeFetch.mockResolvedValueOnce( + tokenAnswer(200, { access_token: 'at', refresh_token: 'rt', id_token: 'id', scope: PLAN_SCOPES }), + ); + verifyIdToken.mockResolvedValueOnce(claims({ nonce: shared.nonce })); + expect(await service().finish(admin, shared.flowId, url)).toBe('instance'); + expect(await findRow(orm(), AiConnections, { scope: 'instance' })).toMatchObject({ kind: 'chatgpt', user: null }); + expect(await findRow(orm(), AiConnections, { user: admin.id })).toBeNull(); + + // An admin who is no longer one cannot finish a sign-in for everyone they started before. + const later = await started(admin.id, 'instance'); + const demoted = { ...admin, role: 'user' }; + const again = callbackUrl({ code: 'c', state: later.state, client_id: 'oaiapp_shared' }); + expect((await refusal(service().finish(demoted, later.flowId, again))).body.code).toBe('forbidden'); + }); + it('CGPT-011: a wrong address is refused before the flow is used, so the right one still works', async () => { const { user } = await makeUser(orm()); const flow = await started(user.id); const wrong = await Promise.all( ['not a url', 'http://localhost:1455/auth/callback?code=x', 'https://chatgpt.com/'].map((pasted) => - refusal(service().finish(user.id, flow.flowId, pasted)), + refusal(service().finish(user, flow.flowId, pasted)), ), ); expect(wrong.map((r) => [r.status, r.body.code])).toEqual([ @@ -223,7 +246,7 @@ describe('finishing a sign-in', () => { tokenAnswer(200, { access_token: 'at', refresh_token: 'rt', id_token: 'id', scope: PLAN_SCOPES }), ); verifyIdToken.mockResolvedValueOnce(claims({ nonce: flow.nonce })); - await service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })); + await service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })); expect(await findRow(orm(), AiConnections, { user: user.id })).not.toBeNull(); }); @@ -232,17 +255,17 @@ describe('finishing a sign-in', () => { const { user: other } = await makeUser(orm()); const flow = await started(user.id); const url = callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' }); - expect((await refusal(service().finish(other.id, flow.flowId, url))).status).toBe(410); - expect((await refusal(service().finish(user.id, crypto.randomUUID(), url))).status).toBe(410); + expect((await refusal(service().finish(other, flow.flowId, url))).status).toBe(410); + expect((await refusal(service().finish(user, crypto.randomUUID(), url))).status).toBe(410); expect( ( await refusal( - service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: 'other', client_id: 'oaiapp_x' })), + service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: 'other', client_id: 'oaiapp_x' })), ) ).body, ).toMatchObject({ code: 'expired' }); // The mismatch used the flow up. - expect((await refusal(service().finish(user.id, flow.flowId, url))).status).toBe(410); + expect((await refusal(service().finish(user, flow.flowId, url))).status).toBe(410); expect(safeFetch).not.toHaveBeenCalled(); }); @@ -252,7 +275,7 @@ describe('finishing a sign-in', () => { ['&client_id=evil', '&client_id=oaiapp_a&client_id=oaiapp_b'].map(async (extra) => { const flow = await started(user.id); return refusal( - service().finish(user.id, flow.flowId, `${CHATGPT_REDIRECT_URI}?code=c&state=${flow.state}${extra}`), + service().finish(user, flow.flowId, `${CHATGPT_REDIRECT_URI}?code=c&state=${flow.state}${extra}`), ); }), ); @@ -283,7 +306,7 @@ describe('finishing a sign-in', () => { expect( ( await refusal( - service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })), + service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })), ) ).status, ).toBe(400); @@ -293,7 +316,7 @@ describe('finishing a sign-in', () => { safeFetch.mockResolvedValueOnce(answer()); verifyIdToken.mockResolvedValueOnce(claims({ nonce: flow.nonce, sub: 'someone-else' })); expect( - (await refusal(service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state })))).body, + (await refusal(service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state })))).body, ).toMatchObject({ code: 'account_mismatch' }); flow = await started(user.id); @@ -302,7 +325,7 @@ describe('finishing a sign-in', () => { ); verifyIdToken.mockResolvedValueOnce(claims({ nonce: flow.nonce })); expect( - await refusal(service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state }))), + await refusal(service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state }))), ).toMatchObject({ status: 403, body: { code: 'not_eligible' } }); // The stored registration is untouched by any of them. expect(openSecrets((await findRow(orm(), AiConnections, { user: user.id }))?.secrets)).toMatchObject({ @@ -317,7 +340,7 @@ describe('finishing a sign-in', () => { expect( ( await refusal( - service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })), + service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })), ) ).status, ).toBe(410); @@ -326,7 +349,7 @@ describe('finishing a sign-in', () => { expect( ( await refusal( - service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })), + service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })), ) ).status, ).toBe(502); diff --git a/shared/src/i18n/ar/assistant.ts b/shared/src/i18n/ar/assistant.ts index f5cac18e0..5284cd7b1 100644 --- a/shared/src/i18n/ar/assistant.ts +++ b/shared/src/i18n/ar/assistant.ts @@ -511,5 +511,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'تحديد يوم إجازة', 'assistant.tool.mark_country_visited': 'تحديد البلد كمُزار', 'assistant.tool.send_collab_message': 'إرسال إلى محادثة الرحلة', + 'assistant.admin.chatgptShared': + 'أنت تشارك اشتراكك الشخصي في ChatGPT مع الجميع على هذا الخادم: تُحتسب محادثاتهم من خطتك. تحقّق من أن خطتك وشروط OpenAI تسمح بذلك.', }; export default assistant; diff --git a/shared/src/i18n/az/assistant.ts b/shared/src/i18n/az/assistant.ts index e8ac8a66c..899dfba5d 100644 --- a/shared/src/i18n/az/assistant.ts +++ b/shared/src/i18n/az/assistant.ts @@ -431,5 +431,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Məzuniyyət günü təyin et', 'assistant.tool.mark_country_visited': 'Ölkəni ziyarət edilmiş kimi işarələ', 'assistant.tool.send_collab_message': 'Səyahət söhbətinə göndər', + 'assistant.admin.chatgptShared': + 'Öz ChatGPT abunəliyini bu serverdəki hər kəslə paylaşırsan: onların söhbətləri sənin planından çıxılır. Planının və OpenAI şərtlərinin buna icazə verdiyini yoxla.', }; export default assistant; diff --git a/shared/src/i18n/br/assistant.ts b/shared/src/i18n/br/assistant.ts index cf0e48b16..5bba966f0 100644 --- a/shared/src/i18n/br/assistant.ts +++ b/shared/src/i18n/br/assistant.ts @@ -431,5 +431,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Lakaat un devezh vakañsoù', 'assistant.tool.mark_country_visited': 'Merkañ ar vro evel gweladennet', 'assistant.tool.send_collab_message': 'Kas d’ar gaozeadenn veaj', + 'assistant.admin.chatgptShared': + 'Rannañ a rez da goumanant ChatGPT gant an holl war ar servijer-mañ: o c’haozeadennoù a vez kontet war da goumanant. Gwir pe ha da goumanant ha divizoù OpenAI a aotre an dra-se.', }; export default assistant; diff --git a/shared/src/i18n/ca/assistant.ts b/shared/src/i18n/ca/assistant.ts index 0046cdcfb..a95e5e958 100644 --- a/shared/src/i18n/ca/assistant.ts +++ b/shared/src/i18n/ca/assistant.ts @@ -434,5 +434,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Marca un dia de vacances', 'assistant.tool.mark_country_visited': 'Marca el país com a visitat', 'assistant.tool.send_collab_message': 'Envia al xat del viatge', + 'assistant.admin.chatgptShared': + 'Comparteixes el teu propi pla de ChatGPT amb tothom en aquest servidor: els seus xats compten contra el teu pla. Comprova que el teu pla i les condicions d’OpenAI ho permeten.', }; export default assistant; diff --git a/shared/src/i18n/cs/assistant.ts b/shared/src/i18n/cs/assistant.ts index db1aa1c7c..71518dc9c 100644 --- a/shared/src/i18n/cs/assistant.ts +++ b/shared/src/i18n/cs/assistant.ts @@ -450,5 +450,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Nastavit den dovolené', 'assistant.tool.mark_country_visited': 'Označit zemi jako navštívenou', 'assistant.tool.send_collab_message': 'Poslat do chatu cesty', + 'assistant.admin.chatgptShared': + 'Sdílíš své vlastní předplatné ChatGPT se všemi na tomto serveru: jejich chaty se počítají do tvého tarifu. Ověř, že to tvůj tarif a podmínky OpenAI dovolují.', }; export default assistant; diff --git a/shared/src/i18n/de/assistant.ts b/shared/src/i18n/de/assistant.ts index f85c7c272..a43127433 100644 --- a/shared/src/i18n/de/assistant.ts +++ b/shared/src/i18n/de/assistant.ts @@ -431,5 +431,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Urlaubstag setzen', 'assistant.tool.mark_country_visited': 'Land als besucht markieren', 'assistant.tool.send_collab_message': 'In den Trip-Chat senden', + 'assistant.admin.chatgptShared': + 'Du teilst dein eigenes ChatGPT-Abo mit allen auf diesem Server: Ihre Chats zählen gegen dein Kontingent. Prüfe, ob dein Abo und die Bedingungen von OpenAI das erlauben.', }; export default assistant; diff --git a/shared/src/i18n/en/assistant.ts b/shared/src/i18n/en/assistant.ts index 31cc0d8fb..ce69f394f 100644 --- a/shared/src/i18n/en/assistant.ts +++ b/shared/src/i18n/en/assistant.ts @@ -426,5 +426,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Set a vacation day', 'assistant.tool.mark_country_visited': 'Mark the country as visited', 'assistant.tool.send_collab_message': 'Send to the trip chat', + 'assistant.admin.chatgptShared': + 'You share your own ChatGPT plan with everyone on this server: their chats count against your plan. Check that your plan and OpenAI’s terms allow this.', }; export default assistant; diff --git a/shared/src/i18n/es/assistant.ts b/shared/src/i18n/es/assistant.ts index 4a6544dcc..df2dc478c 100644 --- a/shared/src/i18n/es/assistant.ts +++ b/shared/src/i18n/es/assistant.ts @@ -429,5 +429,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Marcar un día de vacaciones', 'assistant.tool.mark_country_visited': 'Marcar el país como visitado', 'assistant.tool.send_collab_message': 'Enviar al chat del viaje', + 'assistant.admin.chatgptShared': + 'Compartes tu propio plan de ChatGPT con todos en este servidor: sus chats cuentan contra tu plan. Comprueba que tu plan y las condiciones de OpenAI lo permiten.', }; export default assistant; diff --git a/shared/src/i18n/et/assistant.ts b/shared/src/i18n/et/assistant.ts index 142035def..81dcc967c 100644 --- a/shared/src/i18n/et/assistant.ts +++ b/shared/src/i18n/et/assistant.ts @@ -430,5 +430,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Määra puhkusepäev', 'assistant.tool.mark_country_visited': 'Märgi riik külastatuks', 'assistant.tool.send_collab_message': 'Saada reisivestlusesse', + 'assistant.admin.chatgptShared': + 'Jagad oma ChatGPT tellimust kõigiga selles serveris: nende vestlused arvestatakse sinu paketist. Kontrolli, kas sinu pakett ja OpenAI tingimused seda lubavad.', }; export default assistant; diff --git a/shared/src/i18n/fr/assistant.ts b/shared/src/i18n/fr/assistant.ts index 73a8054e2..1d4ab7ccf 100644 --- a/shared/src/i18n/fr/assistant.ts +++ b/shared/src/i18n/fr/assistant.ts @@ -436,5 +436,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Poser un jour de congé', 'assistant.tool.mark_country_visited': 'Marquer le pays comme visité', 'assistant.tool.send_collab_message': 'Envoyer dans la discussion du voyage', + 'assistant.admin.chatgptShared': + 'Vous partagez votre propre abonnement ChatGPT avec tout le monde sur ce serveur : leurs discussions comptent sur votre forfait. Vérifiez que votre forfait et les conditions d’OpenAI le permettent.', }; export default assistant; diff --git a/shared/src/i18n/gr/assistant.ts b/shared/src/i18n/gr/assistant.ts index 6171b218f..6de05c5cb 100644 --- a/shared/src/i18n/gr/assistant.ts +++ b/shared/src/i18n/gr/assistant.ts @@ -435,5 +435,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Ορισμός ημέρας άδειας', 'assistant.tool.mark_country_visited': 'Σήμανση χώρας ως επισκεφθείσας', 'assistant.tool.send_collab_message': 'Αποστολή στη συνομιλία ταξιδιού', + 'assistant.admin.chatgptShared': + 'Μοιράζεσαι τη δική σου συνδρομή ChatGPT με όλους σε αυτόν τον διακομιστή: οι συνομιλίες τους χρεώνονται στο δικό σου πρόγραμμα. Έλεγξε ότι το πρόγραμμά σου και οι όροι της OpenAI το επιτρέπουν.', }; export default assistant; diff --git a/shared/src/i18n/hu/assistant.ts b/shared/src/i18n/hu/assistant.ts index 66a25b1c9..9990fd968 100644 --- a/shared/src/i18n/hu/assistant.ts +++ b/shared/src/i18n/hu/assistant.ts @@ -436,5 +436,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Szabadnap beállítása', 'assistant.tool.mark_country_visited': 'Ország megjelölése meglátogatottként', 'assistant.tool.send_collab_message': 'Küldés az utazási csevegésbe', + 'assistant.admin.chatgptShared': + 'A saját ChatGPT-előfizetésedet osztod meg mindenkivel ezen a szerveren: a csevegéseik a te keretedből fogynak. Ellenőrizd, hogy az előfizetésed és az OpenAI feltételei ezt megengedik-e.', }; export default assistant; diff --git a/shared/src/i18n/id/assistant.ts b/shared/src/i18n/id/assistant.ts index d7c4e723f..6c72ce1d0 100644 --- a/shared/src/i18n/id/assistant.ts +++ b/shared/src/i18n/id/assistant.ts @@ -411,5 +411,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Tetapkan hari cuti', 'assistant.tool.mark_country_visited': 'Tandai negara sebagai dikunjungi', 'assistant.tool.send_collab_message': 'Kirim ke obrolan perjalanan', + 'assistant.admin.chatgptShared': + 'Kamu membagikan paket ChatGPT milikmu kepada semua orang di server ini: obrolan mereka dihitung dari paketmu. Pastikan paketmu dan ketentuan OpenAI mengizinkannya.', }; export default assistant; diff --git a/shared/src/i18n/it/assistant.ts b/shared/src/i18n/it/assistant.ts index bed41d01c..a356325e5 100644 --- a/shared/src/i18n/it/assistant.ts +++ b/shared/src/i18n/it/assistant.ts @@ -437,5 +437,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Segna un giorno di ferie', 'assistant.tool.mark_country_visited': 'Segna il paese come visitato', 'assistant.tool.send_collab_message': 'Invia nella chat del viaggio', + 'assistant.admin.chatgptShared': + 'Condividi il tuo abbonamento ChatGPT con tutti su questo server: le loro chat contano sul tuo piano. Verifica che il tuo piano e i termini di OpenAI lo consentano.', }; export default assistant; diff --git a/shared/src/i18n/ja/assistant.ts b/shared/src/i18n/ja/assistant.ts index 375f3567c..078bd0f44 100644 --- a/shared/src/i18n/ja/assistant.ts +++ b/shared/src/i18n/ja/assistant.ts @@ -406,5 +406,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': '休暇日を設定', 'assistant.tool.mark_country_visited': '国を訪問済みにする', 'assistant.tool.send_collab_message': '旅行チャットに送信', + 'assistant.admin.chatgptShared': + '自分の ChatGPT プランをこのサーバーの全員と共有します。全員のチャットがあなたのプランの利用枠を使います。プランと OpenAI の規約で許可されているか確認してください。', }; export default assistant; diff --git a/shared/src/i18n/ko/assistant.ts b/shared/src/i18n/ko/assistant.ts index 19f832ba7..c9713572e 100644 --- a/shared/src/i18n/ko/assistant.ts +++ b/shared/src/i18n/ko/assistant.ts @@ -403,5 +403,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': '휴가일 지정', 'assistant.tool.mark_country_visited': '국가를 방문함으로 표시', 'assistant.tool.send_collab_message': '여행 채팅으로 보내기', + 'assistant.admin.chatgptShared': + '내 ChatGPT 요금제를 이 서버의 모든 사람과 공유합니다. 모든 사람의 채팅이 내 요금제 한도에서 차감됩니다. 요금제와 OpenAI 약관에서 허용되는지 확인하세요.', }; export default assistant; diff --git a/shared/src/i18n/nl/assistant.ts b/shared/src/i18n/nl/assistant.ts index 80a3db692..b4eab2881 100644 --- a/shared/src/i18n/nl/assistant.ts +++ b/shared/src/i18n/nl/assistant.ts @@ -432,5 +432,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Vakantiedag instellen', 'assistant.tool.mark_country_visited': 'Land als bezocht markeren', 'assistant.tool.send_collab_message': 'Naar de reischat sturen', + 'assistant.admin.chatgptShared': + 'Je deelt je eigen ChatGPT-abonnement met iedereen op deze server: hun chats tellen mee voor jouw abonnement. Controleer of je abonnement en de voorwaarden van OpenAI dit toestaan.', }; export default assistant; diff --git a/shared/src/i18n/pl/assistant.ts b/shared/src/i18n/pl/assistant.ts index b39276bae..ea8ec5827 100644 --- a/shared/src/i18n/pl/assistant.ts +++ b/shared/src/i18n/pl/assistant.ts @@ -474,5 +474,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Ustaw dzień urlopu', 'assistant.tool.mark_country_visited': 'Oznacz kraj jako odwiedzony', 'assistant.tool.send_collab_message': 'Wyślij na czat podróży', + 'assistant.admin.chatgptShared': + 'Udostępniasz własny plan ChatGPT wszystkim na tym serwerze: ich czaty liczą się do twojego planu. Sprawdź, czy twój plan i warunki OpenAI na to pozwalają.', }; export default assistant; diff --git a/shared/src/i18n/ru/assistant.ts b/shared/src/i18n/ru/assistant.ts index 516878e7a..d1319b8dc 100644 --- a/shared/src/i18n/ru/assistant.ts +++ b/shared/src/i18n/ru/assistant.ts @@ -474,5 +474,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Отметить день отпуска', 'assistant.tool.mark_country_visited': 'Отметить страну как посещённую', 'assistant.tool.send_collab_message': 'Отправить в чат поездки', + 'assistant.admin.chatgptShared': + 'Вы делитесь своей подпиской ChatGPT со всеми на этом сервере: их чаты расходуют ваш лимит. Проверьте, что ваш тариф и условия OpenAI это допускают.', }; export default assistant; diff --git a/shared/src/i18n/sk/assistant.ts b/shared/src/i18n/sk/assistant.ts index da8597d25..45642163a 100644 --- a/shared/src/i18n/sk/assistant.ts +++ b/shared/src/i18n/sk/assistant.ts @@ -450,5 +450,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Nastaviť deň dovolenky', 'assistant.tool.mark_country_visited': 'Označiť krajinu ako navštívenú', 'assistant.tool.send_collab_message': 'Poslať do chatu cesty', + 'assistant.admin.chatgptShared': + 'Zdieľaš svoje vlastné predplatné ChatGPT so všetkými na tomto serveri: ich chaty sa počítajú do tvojho plánu. Over, že to tvoj plán a podmienky OpenAI dovoľujú.', }; export default assistant; diff --git a/shared/src/i18n/sv/assistant.ts b/shared/src/i18n/sv/assistant.ts index 3786e567d..6b335d50c 100644 --- a/shared/src/i18n/sv/assistant.ts +++ b/shared/src/i18n/sv/assistant.ts @@ -430,5 +430,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Sätt en semesterdag', 'assistant.tool.mark_country_visited': 'Markera landet som besökt', 'assistant.tool.send_collab_message': 'Skicka till reschatten', + 'assistant.admin.chatgptShared': + 'Du delar ditt eget ChatGPT-abonnemang med alla på den här servern: deras chattar räknas mot ditt abonnemang. Kontrollera att ditt abonnemang och OpenAIs villkor tillåter det.', }; export default assistant; diff --git a/shared/src/i18n/th/assistant.ts b/shared/src/i18n/th/assistant.ts index 31d588e99..1101b8ea4 100644 --- a/shared/src/i18n/th/assistant.ts +++ b/shared/src/i18n/th/assistant.ts @@ -403,5 +403,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'กำหนดวันลา', 'assistant.tool.mark_country_visited': 'ทำเครื่องหมายว่าเคยไปประเทศนี้', 'assistant.tool.send_collab_message': 'ส่งไปยังแชตทริป', + 'assistant.admin.chatgptShared': + 'คุณกำลังแชร์แพ็กเกจ ChatGPT ของตัวเองกับทุกคนบนเซิร์ฟเวอร์นี้: แชตของทุกคนจะนับรวมในแพ็กเกจของคุณ ตรวจสอบว่าแพ็กเกจและข้อกำหนดของ OpenAI อนุญาต', }; export default assistant; diff --git a/shared/src/i18n/tr/assistant.ts b/shared/src/i18n/tr/assistant.ts index b241da88a..74a49c2e0 100644 --- a/shared/src/i18n/tr/assistant.ts +++ b/shared/src/i18n/tr/assistant.ts @@ -430,5 +430,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'İzin günü belirle', 'assistant.tool.mark_country_visited': 'Ülkeyi ziyaret edildi olarak işaretle', 'assistant.tool.send_collab_message': 'Gezi sohbetine gönder', + 'assistant.admin.chatgptShared': + 'Kendi ChatGPT aboneliğini bu sunucudaki herkesle paylaşıyorsun: sohbetleri senin planından düşer. Planının ve OpenAI koşullarının buna izin verip vermediğini kontrol et.', }; export default assistant; diff --git a/shared/src/i18n/uk/assistant.ts b/shared/src/i18n/uk/assistant.ts index c000bbe20..fcd596c64 100644 --- a/shared/src/i18n/uk/assistant.ts +++ b/shared/src/i18n/uk/assistant.ts @@ -472,5 +472,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Позначити день відпустки', 'assistant.tool.mark_country_visited': 'Позначити країну як відвідану', 'assistant.tool.send_collab_message': 'Надіслати в чат подорожі', + 'assistant.admin.chatgptShared': + 'Ви ділитеся своєю підпискою ChatGPT з усіма на цьому сервері: їхні чати витрачають ваш ліміт. Перевірте, що ваш тариф і умови OpenAI це дозволяють.', }; export default assistant; diff --git a/shared/src/i18n/vi/assistant.ts b/shared/src/i18n/vi/assistant.ts index 6e2e61fd7..f6dde8608 100644 --- a/shared/src/i18n/vi/assistant.ts +++ b/shared/src/i18n/vi/assistant.ts @@ -406,5 +406,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': 'Đặt ngày nghỉ phép', 'assistant.tool.mark_country_visited': 'Đánh dấu quốc gia đã đến', 'assistant.tool.send_collab_message': 'Gửi vào trò chuyện chuyến đi', + 'assistant.admin.chatgptShared': + 'Bạn đang chia sẻ gói ChatGPT của riêng mình với mọi người trên máy chủ này: các cuộc trò chuyện của họ được tính vào gói của bạn. Hãy kiểm tra gói của bạn và điều khoản của OpenAI có cho phép không.', }; export default assistant; diff --git a/shared/src/i18n/zh-TW/assistant.ts b/shared/src/i18n/zh-TW/assistant.ts index bd3b6cabe..bdc14f8df 100644 --- a/shared/src/i18n/zh-TW/assistant.ts +++ b/shared/src/i18n/zh-TW/assistant.ts @@ -400,5 +400,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': '設為休假日', 'assistant.tool.mark_country_visited': '標記國家為已去過', 'assistant.tool.send_collab_message': '傳送到行程聊天', + 'assistant.admin.chatgptShared': + '你正在與此伺服器上的所有人共用你自己的 ChatGPT 訂閱:他們的聊天會計入你的方案。請確認你的方案和 OpenAI 的條款允許這樣做。', }; export default assistant; diff --git a/shared/src/i18n/zh/assistant.ts b/shared/src/i18n/zh/assistant.ts index 6d2f6fc3d..e954c6e13 100644 --- a/shared/src/i18n/zh/assistant.ts +++ b/shared/src/i18n/zh/assistant.ts @@ -400,5 +400,7 @@ const assistant: TranslationStrings = { 'assistant.tool.toggle_vacay_entry': '设为休假日', 'assistant.tool.mark_country_visited': '标记国家为已去过', 'assistant.tool.send_collab_message': '发送到行程聊天', + 'assistant.admin.chatgptShared': + '你正在与此服务器上的所有人共享你自己的 ChatGPT 订阅:他们的聊天会计入你的套餐。请确认你的套餐和 OpenAI 的条款允许这样做。', }; export default assistant;