Skip to content

Commit 573cffe

Browse files
committed
docs: update docs for user sessions plugin
1 parent 605563b commit 573cffe

2 files changed

Lines changed: 116 additions & 0 deletions

File tree

‎dev-demo/globalPlugins.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export const globalPlugins = [
4545
new UserSessionsPlugin({
4646
keyValueAdapter: levelDbAdapter,
4747
canManageOtherUsersSessions: async (adminUser) => adminUser.dbUser.role === 'superadmin',
48+
onNewLogin: async ({ username, reasons, revokePath }) => {
49+
console.log(`New sign-in of ${username} (${reasons.join(', ')}), not you? http://localhost:3123${revokePath}`);
50+
},
4851
}),
4952
new DashboardPlugin({
5053
dashboardConfigsResourceId: 'dashboard_configs',

‎documentation/docs/tutorial/09-Plugins/31-user-sessions.md‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,118 @@ new UserSessionsPlugin({
8989

9090
`resolveCountry` is called only when the header did not give a country.
9191

92+
## Notifying about sign-ins from new devices
93+
94+
Pass `onNewLogin` to be told when a user signs in from a device or a country they have not signed in
95+
from before, e.g. to post it to Slack or send an email to the user:
96+
97+
```ts title="./index.ts"
98+
new UserSessionsPlugin({
99+
keyValueAdapter,
100+
onNewLogin: async ({ username, session, reasons, revokePath }) => {
101+
await fetch(process.env.SLACK_WEBHOOK_URL!, {
102+
method: 'POST',
103+
headers: { 'Content-Type': 'application/json' },
104+
body: JSON.stringify({
105+
text: [
106+
`New sign-in to *${username}* (${reasons.join(', ')})`,
107+
`Device: ${session.device?.browser} on ${session.device?.os}`,
108+
`IP: ${session.ip}, country: ${session.country}`,
109+
`Not you? ${process.env.ADMIN_URL}${revokePath}`,
110+
].join('\n'),
111+
}),
112+
});
113+
},
114+
})
115+
```
116+
117+
How a sign-in is recognized as new:
118+
119+
- On every sign-in the browser gets a random device id in a long-lived cookie, and the plugin remembers
120+
the devices of every user in the adapter, under `<collection>-devices`.
121+
- `new_device`: the browser has no device cookie, its id is unknown for the user, or the cookie came with
122+
another browser or OS than before, which means it was copied. Browser updates are not reported.
123+
- `new_country`: no known device of the user was used from this country. Skipped when the country
124+
is unknown, see [Client IP and country](#client-ip-and-country).
125+
- The IP address alone is not a reason: it changes with every mobile network or VPN. It is passed in
126+
`session`, so you can compare it yourself.
127+
- The first sign-in of a user is reported too: its device and country are new. So after installing the
128+
plugin into a running app, every user is reported once on their next sign-in.
129+
- A device is forgotten 2 years after its last sign-in. Chromium-based browsers keep a cookie 400 days at
130+
most, there a device is forgotten after 400 days without sign-ins.
131+
132+
The callback is not awaited, so a slow request doesn't delay the sign-in, and its errors are only logged.
133+
134+
### Revoke page
135+
136+
`revokePath` leads to a page which works without signing in. It shows the time, device, IP and country
137+
of the sign-in and a button which ends that session.
138+
Ending a session from there also forgets its device, so the next sign-in from it is reported again.
139+
The link works as long as the session would.
140+
141+
`revokePath` starts with `baseUrl` of your app, prepend the origin of your admin panel to make a link
142+
of it. The plugin doesn't take the origin from the request: the sign-in request is sent by whoever signs
143+
in, so its `Host` header could point the link to their own site.
144+
145+
### Sending an email with AWS SES
146+
147+
To email the user about the sign-in, send it with an [email adapter](/docs/tutorial/Adapters/email-adapters),
148+
e.g. the AWS SES one:
149+
150+
```bash
151+
pnpm add @adminforth/email-adapter-aws-ses
152+
```
153+
154+
```ts title="./index.ts"
155+
import AdminForth, { Filters } from 'adminforth';
156+
import UserSessionsPlugin from '@adminforth/user-sessions';
157+
import EmailAdapterAwsSes from '@adminforth/email-adapter-aws-ses';
158+
159+
const emailAdapter = new EmailAdapterAwsSes({
160+
region: 'us-east-1',
161+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
162+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
163+
});
164+
165+
export const admin = new AdminForth({
166+
...
167+
globalPlugins: [
168+
new UserSessionsPlugin({
169+
keyValueAdapter,
170+
onNewLogin: async ({ pk, session, revokePath }) => {
171+
const user = await admin.resource('adminuser').get([Filters.EQ('id', pk)]);
172+
const { browser, os } = session.device ?? {};
173+
174+
const text = [
175+
'Someone signed in to your account.',
176+
'',
177+
`When: ${new Date(session.created_at).toUTCString()}`,
178+
`Device: ${[browser, os].filter(Boolean).join(' on ') || 'unknown'}`,
179+
`IP: ${session.ip ?? 'unknown'}`,
180+
`Country: ${session.country ?? 'unknown'}`,
181+
'',
182+
'If this was you, you can ignore this email. Otherwise, end the session here:',
183+
`${process.env.ADMIN_URL}${revokePath}`,
184+
].join('\n');
185+
186+
const { error } = await emailAdapter.sendEmail(
187+
'noreply@yourapp.com', user.email, text, undefined, 'New sign-in to your account',
188+
);
189+
if (error) {
190+
throw new Error(error);
191+
}
192+
},
193+
}),
194+
],
195+
...
196+
});
197+
```
198+
199+
The sender address or its domain must be verified in SES, see the SES setup steps in the
200+
[Email Invite](/docs/tutorial/Plugins/email-invite#ses) plugin docs. The example sends plain text: if you
201+
add HTML, escape the values of `session`, the IP may come from a request header. An error thrown from the
202+
callback is logged by the plugin and doesn't affect the sign-in.
203+
92204
## Options
93205

94206
| Option | Default | Description |
@@ -99,3 +211,4 @@ new UserSessionsPlugin({
99211
| `resolveCountry` | — | Called when the header did not tell the country |
100212
| `canManageOtherUsersSessions` | — | `(adminUser) => Promise<boolean>`, decides who may see and revoke sessions of other users |
101213
| `lastUsedThrottleSeconds` | `60` | How often `last_used_at` is refreshed. Every refresh is one write to the adapter |
214+
| `onNewLogin` | — | `({ pk, username, session, reasons, revokePath }) => Promise<void>`, called after a sign-in from a new device or country, see [Notifying about sign-ins from new devices](#notifying-about-sign-ins-from-new-devices) |

0 commit comments

Comments
 (0)