Skip to content

Commit ca571d9

Browse files
tea-artistgithub-actions[bot]caoxing9boris-wgaryli27
authored
[sync] feat: add user info popover and Bot badge in grid user cell (T2782) (#2975)
Synced from teableio/teable-ee@6486d30 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Aries X <caoxing9@gmail.com> Co-authored-by: Boris <boris2code@outlook.com> Co-authored-by: Gary Guangyu Li <gary@teable.ai> Co-authored-by: Jocky-Teable <jocky@teable.ai> Co-authored-by: Jun Lu <hammond@teable.io> Co-authored-by: SkyHuang <sky.huang.fe@gmail.com> Co-authored-by: Uno <uno@teable.ai> Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent bd18412 commit ca571d9

576 files changed

Lines changed: 42597 additions & 5325 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/nestjs-backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@
220220
"is-port-reachable": "3.1.0",
221221
"joi": "17.12.2",
222222
"jschardet": "3.1.3",
223+
"kysely": "0.28.9",
223224
"keyv": "4.5.4",
224225
"knex": "3.1.0",
225226
"lodash": "4.17.21",
@@ -241,6 +242,7 @@
241242
"oauth2orize-pkce": "0.1.2",
242243
"object-sizeof": "2.6.4",
243244
"ollama-ai-provider-v2": "3.0.2",
245+
"p-limit": "3.1.0",
244246
"papaparse": "5.4.1",
245247
"passport": "0.7.0",
246248
"passport-github2": "0.1.12",

apps/nestjs-backend/src/cache/redis-native.service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ export class RedisNativeService {
7676
return this.client.hget(key, field);
7777
}
7878

79+
/**
80+
* Get multiple field values from a hash in a single round-trip.
81+
* @param key - Redis hash key
82+
* @param fields - Field names to fetch
83+
* @returns Array of values in the same order as fields (null for missing fields)
84+
*/
85+
async hmget(key: string, ...fields: string[]): Promise<(string | null)[]> {
86+
if (fields.length === 0) return [];
87+
return this.client.hmget(key, ...fields);
88+
}
89+
7990
/**
8091
* Delete one or more fields from a hash. No-op if fields list is empty.
8192
* @param key - Redis hash key
@@ -96,6 +107,16 @@ export class RedisNativeService {
96107
await this.client.expire(key, seconds);
97108
}
98109

110+
/**
111+
* Get remaining TTL (in seconds) for a key.
112+
* Redis semantics:
113+
* - -2: key does not exist
114+
* - -1: key exists but has no associated expire
115+
*/
116+
async ttl(key: string): Promise<number> {
117+
return this.client.ttl(key);
118+
}
119+
99120
/**
100121
* Delete a key.
101122
* @param key - Redis key to delete
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { TableDomain } from '@teable/core';
2+
import knex from 'knex';
3+
import { describe, expect, it } from 'vitest';
4+
import type { IFieldSelectName } from '../features/record/query-builder/field-select.type';
5+
import type { ISelectFormulaConversionContext } from '../features/record/query-builder/sql-conversion.visitor';
6+
import { PostgresProvider } from './postgres.provider';
7+
import { SqliteProvider } from './sqlite.provider';
8+
9+
const emptyTable = new TableDomain({
10+
id: 'tblFormulaUnit',
11+
name: 'Formula Unit',
12+
dbTableName: 'public.tbl_formula_unit',
13+
lastModifiedTime: '2026-04-08T00:00:00.000Z',
14+
fields: [],
15+
});
16+
17+
const toSql = (result: IFieldSelectName) => {
18+
return typeof result === 'string' ? result : result.toQuery();
19+
};
20+
21+
const context: ISelectFormulaConversionContext = {
22+
table: emptyTable,
23+
selectionMap: new Map(),
24+
tableAlias: 'main',
25+
timeZone: 'UTC',
26+
};
27+
28+
describe('convertFormulaToSelectQuery DATETIME_DIFF defaults', () => {
29+
it('defaults DATETIME_DIFF to seconds for postgres select queries', () => {
30+
const provider = new PostgresProvider(knex({ client: 'pg' }));
31+
const sql = toSql(
32+
provider.convertFormulaToSelectQuery(
33+
`DATETIME_DIFF(DATETIME_PARSE("2024-01-03T00:00:00.000Z"), DATETIME_PARSE("2024-01-01T00:00:00.000Z"))`,
34+
context
35+
)
36+
);
37+
38+
expect(sql).toContain('EXTRACT(EPOCH');
39+
expect(sql).not.toContain('/ 86400');
40+
});
41+
42+
it('defaults DATETIME_DIFF to seconds for sqlite select queries', () => {
43+
const provider = new SqliteProvider(knex({ client: 'sqlite3' }));
44+
const sql = toSql(
45+
provider.convertFormulaToSelectQuery(
46+
`DATETIME_DIFF(DATETIME_PARSE("2024-01-03T00:00:00.000Z"), DATETIME_PARSE("2024-01-01T00:00:00.000Z"))`,
47+
context
48+
)
49+
);
50+
51+
expect(sql).toContain('* 24.0 * 60 * 60');
52+
expect(sql).not.toContain('/ 86400');
53+
});
54+
});

apps/nestjs-backend/src/event-emitter/events/event.enum.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,16 @@ export enum Events {
102102

103103
// record source
104104
TABLE_RECORD_CREATE_RELATIVE = 'table.record.create.relative',
105+
106+
// Invitation funnel
107+
INVITATION_EMAIL_SEND = 'invitation.email.send',
108+
INVITATION_LINK_CREATE = 'invitation.link.create',
109+
INVITATION_ACCEPT = 'invitation.accept',
110+
111+
// Access token lifecycle
112+
ACCESS_TOKEN_CREATE = 'access-token.create',
113+
ACCESS_TOKEN_DELETE = 'access-token.delete',
114+
115+
// Table export
116+
TABLE_EXPORT = 'table.export',
105117
}

apps/nestjs-backend/src/features/access-token/access-token.controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
updateAccessTokenRoSchema,
1616
RefreshAccessTokenRo,
1717
} from '@teable/openapi';
18+
import { EmitControllerEvent } from '../../event-emitter/decorators/emit-controller-event.decorator';
19+
import { Events } from '../../event-emitter/events';
1820
import { ZodValidationPipe } from '../../zod.validation.pipe';
1921
import { AccessTokenService } from './access-token.service';
2022

@@ -23,6 +25,7 @@ export class AccessTokenController {
2325
constructor(private readonly accessTokenService: AccessTokenService) {}
2426

2527
@Post()
28+
@EmitControllerEvent(Events.ACCESS_TOKEN_CREATE)
2629
async createAccessToken(
2730
@Body(new ZodValidationPipe(createAccessTokenRoSchema)) body: CreateAccessTokenRo
2831
): Promise<CreateAccessTokenVo> {
@@ -38,6 +41,7 @@ export class AccessTokenController {
3841
}
3942

4043
@Delete(':accessTokenId')
44+
@EmitControllerEvent(Events.ACCESS_TOKEN_DELETE)
4145
async deleteAccessToken(@Param('accessTokenId') accessTokenId: string) {
4246
return await this.accessTokenService.deleteAccessToken(accessTokenId);
4347
}

apps/nestjs-backend/src/features/ai/ai.service.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ export class AiService {
6363
return { type, model, name };
6464
}
6565

66+
/**
67+
* Resolve the model key by matching a body model ID against chatModel lg/md/sm values.
68+
* Model keys are in format type@modelId@name — we compare the modelId segment.
69+
* Falls back to lg if no match is found.
70+
*/
71+
public resolveModelKeyFromBody(
72+
chatModel: { lg?: string; md?: string; sm?: string } | undefined,
73+
bodyModel?: string
74+
): string | undefined {
75+
if (bodyModel) {
76+
const sizes = ['lg', 'md', 'sm'] as const;
77+
for (const size of sizes) {
78+
const key = chatModel?.[size];
79+
if (key && this.parseModelKey(key).model === bodyModel) {
80+
return key;
81+
}
82+
}
83+
}
84+
return chatModel?.lg;
85+
}
86+
6687
/**
6788
* Check if modelKey is an AI Gateway model
6889
* Format: aiGateway@<modelId>@teable
@@ -304,7 +325,6 @@ export class AiService {
304325
lg: lg,
305326
ability,
306327
},
307-
isSpaceChatModel: Boolean(aiIntegrationConfig.chatModel?.lg),
308328
} as IAIConfig;
309329
}
310330

@@ -359,20 +379,6 @@ export class AiService {
359379
};
360380
}
361381

362-
async getToolApiKeys(baseId: string) {
363-
const { appConfig } = await this.settingService.getSetting([SettingKey.APP_CONFIG]);
364-
const { spaceId } = await this.prismaService.base.findUniqueOrThrow({
365-
where: { id: baseId },
366-
});
367-
const aiIntegration = await this.prismaService.integration.findFirst({
368-
where: { resourceId: spaceId, type: IntegrationType.AI },
369-
});
370-
const aiIntegrationConfig = aiIntegration?.config ? JSON.parse(aiIntegration.config) : null;
371-
return {
372-
v0ApiKey: aiIntegrationConfig?.appConfig?.apiKey || appConfig?.apiKey,
373-
};
374-
}
375-
376382
async getSimplifiedAIConfig(baseId: string) {
377383
try {
378384
const config = await this.getAIConfig(baseId);
@@ -554,6 +560,8 @@ export class AiService {
554560
ability: chatModel?.ability,
555561
isInstance,
556562
lgModelKey: chatModel.lg,
563+
mdModelKey: chatModel.md,
564+
smModelKey: chatModel.sm,
557565
};
558566
}
559567

@@ -696,13 +704,14 @@ export class AiService {
696704
*/
697705
private async getGatewayApiModel(modelId: string): Promise<IGatewayApiModel | undefined> {
698706
const models = await this.fetchGatewayModelsFromApi();
707+
const normalize = (s: string) =>
708+
s.split('/').pop()!.replaceAll('.', '').replaceAll('-', '').toLowerCase();
709+
const stripDateSuffix = (s: string) => s.replace(/\d{8,}$/, '');
699710
return models.find((m) => {
700-
const modelIdParts = modelId.split('/');
701-
const normalizedModelId = modelIdParts[modelIdParts.length - 1]
702-
.replaceAll('.', '')
703-
.replaceAll('-', '');
704-
const normalizedGatewayModelId = m.id.replaceAll('.', '').replaceAll('-', '').split('/')?.[1];
705-
return normalizedGatewayModelId?.toLowerCase() === normalizedModelId?.toLowerCase();
711+
const a = normalize(modelId);
712+
const b = normalize(m.id);
713+
if (a === b) return true;
714+
return stripDateSuffix(a) === stripDateSuffix(b);
706715
});
707716
}
708717

apps/nestjs-backend/src/features/ai/util.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,28 @@ const createOpenAICompatibleWrapper = (
8888
});
8989
};
9090

91+
const createClaudeCodeWrapper = (
92+
options: Parameters<typeof createAnthropic>[0]
93+
): ReturnType<typeof createAnthropic> => {
94+
const baseFetch = createFixingFetch();
95+
const claudeCodeDefaultUa = 'claude-cli/2.1.71 (external, cli)';
96+
return createAnthropic({
97+
...options,
98+
fetch: async (input, init) => {
99+
const initHeaders = (init?.headers ?? {}) as Record<string, string>;
100+
const ua = initHeaders['user-agent'];
101+
return baseFetch(input, {
102+
...init,
103+
headers: {
104+
...init?.headers,
105+
// eslint-disable-next-line @typescript-eslint/naming-convention
106+
'user-agent': ua?.includes('claude-cli') ? ua : claudeCodeDefaultUa,
107+
},
108+
});
109+
},
110+
});
111+
};
112+
91113
export const modelProviders = {
92114
[LLMProviderType.OPENAI]: createOpenAI,
93115
[LLMProviderType.ANTHROPIC]: createAnthropic,
@@ -105,6 +127,7 @@ export const modelProviders = {
105127
[LLMProviderType.AMAZONBEDROCK]: createAmazonBedrock,
106128
[LLMProviderType.OPENROUTER]: createOpenRouter,
107129
[LLMProviderType.OPENAI_COMPATIBLE]: createOpenAICompatibleWrapper,
130+
[LLMProviderType.CLAUDE_CODE]: createClaudeCodeWrapper,
108131
// AI_GATEWAY is handled separately in ai.service.ts using createGateway from 'ai'
109132
} as const;
110133

apps/nestjs-backend/src/features/auth/auth.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ export class AuthService {
3939
}
4040
}
4141

42-
async getTempToken() {
42+
async getTempToken(expiresIn: string = '10m', userId?: string, allowSystemUser?: boolean) {
4343
const payload: IJwtAuthInfo = {
44-
userId: this.cls.get('user.id'),
44+
userId: userId ?? this.cls.get('user.id'),
45+
...(allowSystemUser ? { allowSystemUser: true } : {}),
4546
};
46-
const expiresIn = '10m';
4747
return {
4848
accessToken: await this.jwtService.signAsync(payload, { expiresIn }),
4949
expiresTime: new Date(Date.now() + ms(expiresIn)).toISOString(),

apps/nestjs-backend/src/features/auth/guard/permission.guard.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ExecutionContext } from '@nestjs/common';
22
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
33
import { Reflector } from '@nestjs/core';
4-
import { ANONYMOUS_USER_ID, HttpErrorCode, isAnonymous, type Action } from '@teable/core';
4+
import { ANONYMOUS_USER_ID, HttpErrorCode, IdPrefix, isAnonymous, type Action } from '@teable/core';
55
import cookie from 'cookie';
66
import { ClsService } from 'nestjs-cls';
77
import { CustomHttpException } from '../../../custom.exception';
@@ -155,12 +155,15 @@ export class PermissionGuard {
155155
resourceId,
156156
permissions
157157
);
158-
// Set user to anonymous for share context
159-
this.cls.set('user', {
160-
id: ANONYMOUS_USER_ID,
161-
name: ANONYMOUS_USER_ID,
162-
email: '',
163-
});
158+
// Preserve logged-in user identity for allowEdit; fall back to anonymous
159+
const currentUserId = this.cls.get('user.id');
160+
if (!currentUserId || isAnonymous(currentUserId)) {
161+
this.cls.set('user', {
162+
id: ANONYMOUS_USER_ID,
163+
name: ANONYMOUS_USER_ID,
164+
email: '',
165+
});
166+
}
164167
this.cls.set('permissions', ownPermissions);
165168
return true;
166169
}
@@ -297,6 +300,22 @@ export class PermissionGuard {
297300
if (!shareId) {
298301
return undefined;
299302
}
303+
// Skip share path for endpoints without @Permissions (e.g. /user/me),
304+
// otherwise baseSharePermissionCheck throws ForbiddenException.
305+
const permissions = this.reflector.getAllAndOverride<Action[] | undefined>(PERMISSIONS_KEY, [
306+
context.getHandler(),
307+
context.getClass(),
308+
]);
309+
if (!permissions?.length) {
310+
return undefined;
311+
}
312+
// Skip share check when the target resource is outside the share scope.
313+
// e.g. space-level endpoints (GET /space, POST /share/:id/base/copy with spaceId in body)
314+
// should use the user's own permissions, not the share's.
315+
const resourceId = this.getResourceId(context) || this.defaultResourceId(context);
316+
if (!resourceId || resourceId.startsWith(IdPrefix.Space)) {
317+
return undefined;
318+
}
300319
return await this.baseSharePermissionCheck(context, shareId);
301320
}
302321

@@ -383,9 +402,10 @@ export class PermissionGuard {
383402
*
384403
* Priority flow:
385404
* 1. RESOURCE-level: exclusively use resource-specific auth (base share > template)
386-
* 2. Early base share check for PUBLIC or anonymous requests when header is present
405+
* 2. Share link check — when share header is present, share permissions are the ceiling
406+
* for ALL users (anonymous or authenticated), so personal role never exceeds the link
387407
* 3. Anonymous user handling (template / USER-level)
388-
* 4. Authenticated user: standard check, with fallback for PUBLIC endpoints
408+
* 4. Authenticated user: standard check, with PUBLIC fallback
389409
*/
390410
protected async permissionCheckWithPublicFallback(
391411
context: ExecutionContext,
@@ -406,10 +426,8 @@ export class PermissionGuard {
406426
// No valid resource auth header — fall through to normal checks
407427
}
408428

409-
// 2. Early base share check for PUBLIC or anonymous requests
410-
const shouldTryBaseShareEarly =
411-
baseShareHeader && (allowAnonymousType === AllowAnonymousType.PUBLIC || this.isAnonymous());
412-
if (shouldTryBaseShareEarly) {
429+
// 2. Share link — permissions are bounded by the link, regardless of user role
430+
if (baseShareHeader) {
413431
const result = await this.tryBaseSharePermissionCheck(context, baseShareHeader);
414432
if (result !== undefined) return result;
415433
}
@@ -419,7 +437,7 @@ export class PermissionGuard {
419437
return this.resolveAnonymousPermission(context, allowAnonymousType);
420438
}
421439

422-
// 4. Authenticated user: standard check, with fallback for PUBLIC endpoints
440+
// 4. Authenticated user: standard check, with PUBLIC fallback
423441
try {
424442
return await permissionCheck();
425443
} catch (error) {

0 commit comments

Comments
 (0)