mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(assistant): an admin can share their ChatGPT plan with everyone
The connection for everyone can now also be the admin's own ChatGPT sign-in. Whether a plan may be shared that way is the self-hoster's call under their plan and OpenAI's terms; the form says what it means before the sign-in. The sign-in starts on the admin route and finishes on the shared one, which refuses a sign-in for everyone to anyone who is not an admin. The ChatGPT return reads the clipboard only once the paste handler exists.
This commit is contained in:
@@ -155,7 +155,10 @@ export const aiApi = {
|
||||
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
|
||||
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
|
||||
|
||||
chatgptStart: (): Promise<AiAuthStartResponse> => apiClient.post('/ai/connect/chatgpt/start', {}).then((r) => r.data),
|
||||
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
|
||||
apiClient
|
||||
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
|
||||
.then((r) => r.data),
|
||||
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
|
||||
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
|
||||
chatgptSignOut: (): Promise<SuccessResponse> =>
|
||||
|
||||
@@ -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<AiConnectionScope, readonly AiConnectMode[]> = {
|
||||
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
|
||||
</div>
|
||||
{scope === 'instance' && prefill && <SettingsHint>{t('assistant.admin.prefilled')}</SettingsHint>}
|
||||
|
||||
{mode === 'chatgpt' && scope === 'instance' && (
|
||||
// Sharing one person's plan with everyone is the admin's call to make; TREK only says what it means.
|
||||
<p
|
||||
role="note"
|
||||
className="m-0 rounded-lg bg-warning-soft px-3 py-2 leading-snug text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('assistant.admin.chatgptShared')}
|
||||
</p>
|
||||
)}
|
||||
{mode === 'chatgpt' && <ChatGptPasteStep chatgpt={chatgpt} onOtherProvider={() => setMode('key')} />}
|
||||
{mode === 'key' && <KeyForm scope={scope} keyConnect={keyConnect} />}
|
||||
{mode === 'custom' && <CustomForm custom={custom} />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<AiConnectionView> {
|
||||
const row = await this.connections.findInstance();
|
||||
if (!row) throw aiRefusal('not_found', 'No connection for everyone yet');
|
||||
return viewOf(row);
|
||||
}
|
||||
|
||||
async instanceConnection(): Promise<AiAdminConnectionResponse> {
|
||||
const row = await this.connections.findInstance();
|
||||
return {
|
||||
|
||||
@@ -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<AiAuthStartResponse> {
|
||||
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<AiAuthStartResponse> {
|
||||
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<void> {
|
||||
await this.settings.assertCanConnectOwn(userId);
|
||||
async finish(user: { id: number; role: string }, flowId: string, pastedUrl: string): Promise<AiConnectionScope> {
|
||||
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<void> {
|
||||
const row = await this.connections.findForUser(userId);
|
||||
async signOut(userId: number, scope: AiConnectionScope = 'user'): Promise<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('InMemoryAiAuthFlowStore', () => {
|
||||
const flow = (userId: number, expiresAt: number): ChatgptAuthFlow => ({
|
||||
provider: 'chatgpt',
|
||||
userId,
|
||||
scope: 'user',
|
||||
verifier: 'v',
|
||||
state: 's',
|
||||
nonce: 'n',
|
||||
|
||||
@@ -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<unknown>) {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user