Skip to content

Commit 0e73e4c

Browse files
committed
Merge branch 'main' of github.com:devforth/adminforth
2 parents 5e2c4e8 + 7d7345c commit 0e73e4c

12 files changed

Lines changed: 161 additions & 75 deletions

File tree

adminforth/dataConnectors/clickhouse.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,6 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
530530
}).join(', ');
531531
const tableName = resource.table;
532532

533-
console.log('getDataWithOriginalTypes called with filters', JSON.stringify(filters), 'and sort', JSON.stringify(sort));
534533
const { where, params } = this.whereClause(resource, filters);
535534

536535
const orderBy = sort.length ? `ORDER BY ${sort.map((s) => `${s.field} ${this.SortDirectionsMap[s.direction]}`).join(', ')}` : '';

adminforth/documentation/docs/tutorial/03-Customization/12-security.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,46 @@ export const admin = new AdminForth({
242242
```
243243
244244
Now, if a user’s field `status` is changed to "banned", they won’t be able to perform any actions and moreover will be automatically logged out upon accessing the page.
245+
246+
## RateLimiter for API
247+
248+
### Import
249+
```ts
250+
import { RateLimiter } from "adminforth";
251+
```
252+
253+
### Usage
254+
```ts
255+
import { RateLimiter } from "adminforth";
256+
257+
const UserRateLimiter = new RateLimiter("20/1d");
258+
259+
app.post(
260+
`${ADMIN_BASE_URL}/api/some-api/`,
261+
admin.express.authorize(async (req: any, res: any) => {
262+
263+
const allowed = await UserRateLimiter.consume(req.user.id);
264+
265+
if (!allowed) {
266+
res.status(429).json({
267+
error: "Rate limit exceeded"
268+
});
269+
return;
270+
}
271+
272+
// your API logic here
273+
})
274+
);
275+
```
276+
277+
### Limit format
278+
"20/1d"
279+
This means that a user is allowed to make up to 20 requests within one day, and once this limit is reached, any further requests will be blocked until the 24-hour period resets.
280+
281+
### Supported time units
282+
- s → seconds (10s)
283+
- m → minutes (5m)
284+
- h → hours (1h)
285+
- d → days (1d)
286+
287+
> ☝ Сonsume(key) is used to check whether a specific key such as a userId, IP address, or any other identifier has exceeded its allowed request limit. If the limit has not been reached, it returns true, meaning the request is allowed to proceed.

adminforth/documentation/docs/tutorial/05-Adapters/09-chat-surface-adapters.md

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,45 +17,61 @@ Create a Telegram bot with BotFather and add the token to `.env`:
1717

1818
```env title=".env"
1919
TELEGRAM_BOT_TOKEN=your_bot_token
20+
TELEGRAM_BOT_USERNAME=your_bot_username_without_at
2021
TELEGRAM_WEBHOOK_SECRET=your_random_secret
2122
```
2223

23-
The webhook secret confirms that the request came through Telegram. Your app should still map the Telegram user id to a real AdminForth admin user before running the agent.
24+
The webhook secret confirms that the request came through Telegram.
2425

25-
## Admin user field `telegramId`
26+
## Admin user field `externalUserId`
2627

27-
To map Telegram users to AdminForth admin users, the adapter looks up an admin user record by Telegram user id.
28-
By default it expects the admin user resource to have a field named `telegramId`.
28+
External chat accounts are linked by the Agent plugin, not by the Telegram adapter directly. The plugin stores linked external user ids in a JSON field on the AdminForth auth user resource.
2929

30-
Add this field to your `adminuser` resource:
30+
By default this field is named `externalUserId`. Add it to your `adminuser` resource:
3131

3232
```ts
3333
{
34-
name: 'telegramId',
35-
type: AdminForthDataTypes.STRING,
36-
showIn: ['show', 'edit', 'create'],
34+
name: 'externalUserId',
35+
type: AdminForthDataTypes.JSON,
3736
},
3837
```
3938

40-
Also add the matching column to your database schema and run a migration. For example, with Prisma:
39+
Also add the matching column to your database schema and run a migration. For example, with Prisma and PostgreSQL:
4140

4241
```prisma title="schema.prisma"
4342
model adminuser {
4443
// existing fields
45-
telegramId String?
44+
externalUserId Json?
4645
}
4746
```
4847

48+
For Prisma SQLite, store the same field as text:
49+
50+
```prisma title="schema.prisma"
51+
model adminuser {
52+
// existing fields
53+
externalUserId String?
54+
}
55+
```
56+
57+
AdminForth should still define this resource column as `AdminForthDataTypes.JSON`; the SQLite connector serializes it into the text column and parses it back.
58+
4959
Then create and apply the migration using your app's migration scripts:
5060

5161
```bash
52-
pnpm makemigration --name add-adminuser-telegram-id
62+
pnpm makemigration --name add-adminuser-external-user-id
5363
pnpm migrate:local
5464
```
5565

56-
After the migration, set `telegramId` on the admin user record to the numeric Telegram user id that should be allowed to use the bot.
66+
When a Telegram account is linked, the field stores data like this:
5767

58-
If your field is named differently, configure `adminUserTelegramIdField` option (see below).
68+
```json
69+
{
70+
"telegram": "123456789"
71+
}
72+
```
73+
74+
If your field is named differently, configure `chatExternalIdsField` on the Agent plugin.
5975

6076
Register the adapter in the Agent plugin:
6177

@@ -80,31 +96,41 @@ new AdminForthAgent({
8096
//diff-add
8197
botToken: process.env.TELEGRAM_BOT_TOKEN as string,
8298
//diff-add
83-
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET,
99+
botUsername: process.env.TELEGRAM_BOT_USERNAME,
84100
//diff-add
85-
adminUserTelegramIdField: 'telegramId',
101+
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET,
86102
//diff-add
87103
}),
88104
//diff-add
89105
],
106+
// optional, defaults to 'externalUserId'
107+
//diff-add
108+
chatExternalIdsField: 'externalUserId',
90109
});
91110
```
92111

112+
When `botUsername` is configured, the Agent plugin adds **Chat Surfaces** to the user menu settings pages. A logged-in AdminForth user can open that page and click **Connect**. The Telegram adapter returns a URL like:
113+
114+
```txt
115+
https://t.me/<botUsername>?start=<link-token>
116+
```
117+
118+
After the user starts the bot with that token, AdminForth stores the Telegram user id in `externalUserId.telegram`. The same page also supports reconnecting and disconnecting the Telegram account.
119+
120+
You can also prefill the JSON field manually if you do not want to use the connect page.
121+
93122
## Adapter options
94123

95124
All options for `new TelegramChatSurfaceAdapter(options)`:
96125

97126
- `botToken` (string, required) — Telegram bot token from BotFather.
127+
- `botUsername` (string, optional) — bot username used to build the account-link URL for the **Chat Surfaces** settings page.
98128
- `webhookSecret` (string, optional) — secret token configured in Telegram `setWebhook`.
99129
- `streamingMode` (`draft` | `typing` | `off`, optional) — streaming behavior for Telegram responses.
100130
- Default: `draft`.
101131
- Note: Telegram drafts work only in private chats. In non-private chats the adapter automatically falls back from `draft` to `typing`.
102132
- `draftUpdateIntervalMs` (number, optional) — throttle for draft preview updates.
103133
- Default: `650`.
104-
- `adminUserTelegramIdField` (string, optional) — admin user field that stores Telegram user id.
105-
- Default: `telegramId`.
106-
- `adminUserResourceId` (string, optional) — AdminForth resource id that stores admin users.
107-
- Default: `adminuser`.
108134

109135
The plugin exposes this webhook endpoint:
110136

adminforth/documentation/docs/tutorial/08-Plugins/01-agent.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -289,30 +289,27 @@ The plugin adds a chat surface to the admin UI, keeps session history per admin
289289
By default, the Agent plugin exposes a chat surface inside the AdminForth admin UI.
290290
If you want to talk to the same agent from external chat products (Telegram, etc.), connect a **chat surface adapter**.
291291

292-
The adapter is registered in the plugin config via `chatSurfaceAdapters` and the plugin exposes an HTTP webhook endpoint for it.
293-
294-
Example (Telegram):
292+
Register adapters through `chatSurfaceAdapters`:
295293

296294
```ts
297295
import AdminForthAgent from '@adminforth/agent';
298-
import TelegramChatSurfaceAdapter from '@adminforth/chat-surface-adapter-telegram';
296+
import SomeChatSurfaceAdapter from '@adminforth/some-chat-surface-adapter';
299297

300298
new AdminForthAgent({
301299
// ...modes, sessionResource, turnResource, etc.
302300
chatSurfaceAdapters: [
303-
new TelegramChatSurfaceAdapter({
304-
botToken: process.env.TELEGRAM_BOT_TOKEN as string,
305-
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET,
306-
// optional
307-
adminUserTelegramIdField: 'telegramId',
301+
new SomeChatSurfaceAdapter({
302+
// adapter-specific options
308303
}),
309304
],
310305
});
311306
```
312307

313-
Next steps (Telegram bot setup, webhook URL, required `telegramId` field on the admin user resource, and all adapter options) are documented here:
308+
When an adapter supports account linking, the Agent plugin adds a user menu settings page named **Chat Surfaces** where logged-in users can connect, reconnect, and disconnect external accounts.
309+
310+
For Telegram setup, including required user fields, webhook URL, environment variables, and adapter options, see:
314311

315-
- Telegram Chat Surface Adapter: `/docs/tutorial/Adapters/chat-surface-adapter-telegram`
312+
- [Telegram Chat Surface Adapter](https://adminforth.dev/docs/tutorial/Adapters/chat-surface-adapter-telegram/)
316313

317314
## Debugging agent turns
318315

adminforth/modules/restApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
718718

719719

720720
if (!userRecord) {
721+
response.setStatus(401);
721722
return { error: INVALID_MESSAGE };
722723
}
723724

@@ -748,6 +749,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
748749
});
749750
}
750751
} else {
752+
response.setStatus(401);
751753
return { error: INVALID_MESSAGE };
752754
}
753755

adminforth/spa/src/App.vue

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,32 +54,34 @@
5454
</svg>
5555
</button>
5656
</div>
57-
<div class="z-50 hidden my-4 text-base list-none bg-lightUserMenuBackground divide-y divide-lightUserMenuBorder text-lightUserMenuText rounded shadow dark:shadow-black dark:bg-darkUserMenuBackground dark:divide-darkUserMenuBorder text-darkUserMenuText dark:shadow-black" id="dropdown-user">
58-
<div class="px-4 py-3" role="none">
59-
<p class="text-sm text-gray-900 dark:text-darkNavbarText" role="none" v-if="coreStore.userFullname">
60-
{{ coreStore.userFullname }}
61-
</p>
62-
<p class="text-sm font-medium text-gray-900 truncate dark:text-darkSidebarText" role="none">
63-
{{ coreStore.username }}
64-
</p>
57+
<Teleport to="body">
58+
<div class="z-50 hidden my-4 text-base list-none bg-lightUserMenuBackground divide-y divide-lightUserMenuBorder text-lightUserMenuText rounded shadow dark:shadow-black dark:bg-darkUserMenuBackground dark:divide-darkUserMenuBorder text-darkUserMenuText dark:shadow-black" id="dropdown-user">
59+
<div class="px-4 py-3" role="none">
60+
<p class="text-sm text-gray-900 dark:text-darkNavbarText" role="none" v-if="coreStore.userFullname">
61+
{{ coreStore.userFullname }}
62+
</p>
63+
<p class="text-sm font-medium text-gray-900 truncate dark:text-darkSidebarText" role="none">
64+
{{ coreStore.username }}
65+
</p>
66+
</div>
67+
68+
<ul class="py-1" role="none">
69+
<li v-for="c in userMenuComponents" class="bg-lightUserMenuItemBackground hover:bg-lightUserMenuItemBackgroundHover text-lightUserMenuItemText hover:text-lightUserMenuItemText dark:bg-darkUserMenuItemBackground dark:hover:bg-darkUserMenuItemBackgroundHover dark:text-darkUserMenuItemText dark:hover:darkUserMenuItemTextHover" >
70+
<component
71+
:is="getCustomComponent(c)"
72+
:meta="c.meta"
73+
:adminUser="coreStore.adminUser"
74+
/>
75+
</li>
76+
<li v-if="coreStore?.config?.settingPages && coreStore.config.settingPages.length > 0">
77+
<UserMenuSettingsButton />
78+
</li>
79+
<li>
80+
<button @click="logout" class="cursor-pointer flex items-center gap-1 block px-4 py-2 text-sm bg-lightUserMenuItemBackground hover:bg-lightUserMenuItemBackgroundHover text-lightUserMenuItemText hover:text-lightUserMenuItemText dark:bg-darkUserMenuItemBackground dark:hover:bg-darkUserMenuItemBackgroundHover dark:text-darkUserMenuItemText dark:hover:darkUserMenuItemTextHover w-full" role="menuitem">{{ $t('Sign out') }}</button>
81+
</li>
82+
</ul>
6583
</div>
66-
67-
<ul class="py-1" role="none">
68-
<li v-for="c in userMenuComponents" class="bg-lightUserMenuItemBackground hover:bg-lightUserMenuItemBackgroundHover text-lightUserMenuItemText hover:text-lightUserMenuItemText dark:bg-darkUserMenuItemBackground dark:hover:bg-darkUserMenuItemBackgroundHover dark:text-darkUserMenuItemText dark:hover:darkUserMenuItemTextHover" >
69-
<component
70-
:is="getCustomComponent(c)"
71-
:meta="c.meta"
72-
:adminUser="coreStore.adminUser"
73-
/>
74-
</li>
75-
<li v-if="coreStore?.config?.settingPages && coreStore.config.settingPages.length > 0">
76-
<UserMenuSettingsButton />
77-
</li>
78-
<li>
79-
<button @click="logout" class="cursor-pointer flex items-center gap-1 block px-4 py-2 text-sm bg-lightUserMenuItemBackground hover:bg-lightUserMenuItemBackgroundHover text-lightUserMenuItemText hover:text-lightUserMenuItemText dark:bg-darkUserMenuItemBackground dark:hover:bg-darkUserMenuItemBackgroundHover dark:text-darkUserMenuItemText dark:hover:darkUserMenuItemTextHover w-full" role="menuitem">{{ $t('Sign out') }}</button>
80-
</li>
81-
</ul>
82-
</div>
84+
</Teleport>
8385
</div>
8486
</div>
8587
</nav>
@@ -183,7 +185,7 @@
183185
</style>
184186

185187
<script setup lang="ts">
186-
import { computed, onMounted, ref, watch, onBeforeMount } from 'vue';
188+
import { computed, nextTick, onMounted, ref, watch, onBeforeMount } from 'vue';
187189
import { RouterView } from 'vue-router';
188190
import { Dropdown } from 'flowbite'
189191
import './index.scss'
@@ -310,11 +312,14 @@ watch(route, () => {
310312
});
311313
312314
313-
watch(dropdownUserButton, (dropdownUserButton) => {
315+
watch(dropdownUserButton, async (dropdownUserButton) => {
314316
if (dropdownUserButton) {
317+
await nextTick();
318+
const dropdownUser = document.querySelector('#dropdown-user') as HTMLElement;
319+
const dropdownUserTrigger = document.querySelector('[data-dropdown-toggle="dropdown-user"]') as HTMLElement;
315320
const dd = new Dropdown(
316-
document.querySelector('#dropdown-user') as HTMLElement,
317-
document.querySelector('[data-dropdown-toggle="dropdown-user"]') as HTMLElement,
321+
dropdownUser,
322+
dropdownUserTrigger,
318323
);
319324
closeUserMenuDropdown = () => {
320325
dd.hide();

adminforth/spa/src/components/Sidebar.vue

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@
6767
<button @click="clickOnMenuItem(i)" type="button" class="af-sidebar-expand-button flex items-center w-full px-3.5 py-2 text-base text-lightSidebarText rounded-default transition duration-75 group hover:bg-lightSidebarItemHover hover:text-lightSidebarTextHover dark:text-darkSidebarText dark:hover:bg-darkSidebarHover dark:hover:text-darkSidebarTextHover"
6868
:class="opened.includes(i) ? 'af-sidebar-dropdown-expanded' : 'af-sidebar-dropdown-collapsed'"
6969
:aria-controls="`dropdown-example${i}`"
70-
:data-collapse-toggle="`dropdown-example${i}`"
7170
>
7271
<component v-if="item.icon" :is="getIcon(item.icon)" class="w-5 h-5 text-lightSidebarIcons group-hover:text-lightSidebarIconsHover transition duration-75 dark:group-hover:text-darkSidebarIconsHover dark:text-darkSidebarIcons" ></component>
7372
<span class="overflow-hidden flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">{{ item.label }}
@@ -114,7 +113,6 @@
114113
<button @click="clickOnMenuItem(i)" type="button" class="af-sidebar-expand-button relative flex items-center h-10 w-full px-3.5 py-2 text-base text-lightSidebarText rounded-default group hover:bg-lightSidebarItemHover hover:text-lightSidebarTextHover dark:text-darkSidebarText dark:hover:bg-darkSidebarHover dark:hover:text-darkSidebarTextHover"
115114
:class="opened.includes(i) ? 'af-sidebar-dropdown-expanded' : 'af-sidebar-dropdown-collapsed'"
116115
:aria-controls="`dropdown-example${i}`"
117-
:data-collapse-toggle="`dropdown-example${i}`"
118116
>
119117
<component v-if="item.icon" :is="getIcon(item.icon)" class="min-w-5 min-h-5 text-lightSidebarIcons group-hover:text-lightSidebarIconsHover transition duration-75 dark:group-hover:text-darkSidebarIconsHover dark:text-darkSidebarIcons" ></component>
120118

adminforth/spa/src/utils/listUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export async function startBulkAction(actionId: string, resource: AdminForthReso
6161
if (action?.confirm) {
6262
const confirmed = await confirm({
6363
title: action.confirm,
64-
message: `${t('Deleting')} ${checkboxes.value.length} ${checkboxes.value.length === 1 ? t('item') : t('items')}. ${t('This process is irreversible.')}`,
64+
message: t('Deleting {count} item. This process is irreversible. | Deleting {count} items. This process is irreversible.', { count: checkboxes.value.length }),
6565
});
6666
if (!confirmed) {
6767
return;

adminforth/spa/src/utils/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export async function callApi({path, method, body, headers, silentError = false,
141141
const fullPath = `${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}${path}`;
142142
try {
143143
const r = await fetch(fullPath, options);
144-
if (r.status == 401 ) {
144+
if (r.status == 401 && !path.includes('/login')) {
145145
useUserStore().unauthorize();
146146
useCoreStore().resetAdminUser();
147147
await redirectToLogin();
@@ -700,6 +700,9 @@ export function generateMessageHtmlForRecordChange(changedFields: Record<string,
700700
const items = Object.keys(changedFields || {}).map(key => {
701701
const column = coreStore.resource?.columns?.find((c: any) => c.name === key);
702702
const label = column?.label || key;
703+
if (column?.masked) {
704+
return `<li class="truncate"><strong>${escapeHtml(label)}</strong>: <em>${escapeHtml(t('changed'))}</em></li>`;
705+
}
703706
const oldV = escapeHtml(changedFields[key].oldValue);
704707
const newV = escapeHtml(changedFields[key].newValue);
705708
return `<li class="truncate"><strong>${escapeHtml(label)}</strong>: <span class="af-old-value text-muted">${oldV}</span> &#8594; <span class="af-new-value">${newV}</span></li>`;

adminforth/types/Back.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface IConfigValidator {
3636

3737
export interface IAdminForthHttpResponse {
3838
setHeader: (key: string, value: string) => void,
39-
setStatus: (code: number, message: string) => void,
39+
setStatus: (code: number, message?: string) => void,
4040
blobStream: () => Writable,
4141
};
4242

0 commit comments

Comments
 (0)