Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/inactive-user-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added an "Inactive" status with an explanatory tooltip to the Users table for accounts that haven't been used for 30+ days. Regular users are inactive when they haven't logged in (or had session activity) for 30+ days; API-only users are inactive when their token has made no API requests for 30+ days. Fleet now records each user's last login time in a new `last_login_at` field and reports last session activity in a new `last_activity_at` field, both returned by the users API.
4 changes: 4 additions & 0 deletions docs/REST API/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14608,6 +14608,8 @@ None.
"mfa_enabled": false,
"global_role": null,
"api_only": false,
"last_login_at": "2020-12-10T04:15:20Z",
"last_activity_at": "2020-12-10T04:32:41Z",
"fleets": [
{
"id": 1,
Expand Down Expand Up @@ -14876,6 +14878,8 @@ Returns all information about a specific user.
"mfa_enabled": false,
"global_role": "admin",
"api_only": false,
"last_login_at": "2020-12-10T05:24:27Z",
"last_activity_at": "2020-12-10T05:31:03Z",
"fleets": []
}
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/__mocks__/userMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const DEFAULT_USER_MOCK: IUser = {
sso_enabled: false,
global_role: "admin",
api_only: false,
last_login_at: null,
last_activity_at: null,
teams: [],
fleets: [],
};
Expand Down
5 changes: 5 additions & 0 deletions frontend/components/StatusIndicator/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
background-color: $ui-offline;
}
}
&--inactive {
&:before {
background-color: $ui-warning;
}
}
&--invite-pending {
&:before {
background-color: $ui-warning;
Expand Down
6 changes: 6 additions & 0 deletions frontend/interfaces/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export interface IUser {
mfa_enabled?: boolean;
global_role: UserRole | null;
api_only: boolean;
/** Last time the user logged in. `null` if the user has never logged in. */
last_login_at: string | null;
/** Last time the user made an authenticated request with a live session.
* This is the inactivity signal for API-only users. `null` if the user has
* no live session. */
last_activity_at: string | null;
teams: ITeam[];
fleets: ITeam[]; // This will eventually replace `teams`, but for now we need both to avoid breaking changes.
api_endpoints?: IApiEndpointRef[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import createMockUser from "__mocks__/userMock";
import { IInvite } from "interfaces/invite";

import { combineDataSets } from "./UsersTableConfig";

const daysAgo = (days: number): string =>
new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();

const createMockInvite = (overrides?: Partial<IInvite>): IInvite => ({
created_at: daysAgo(1),
updated_at: daysAgo(1),
id: 1,
invited_by: 99,
email: "invitee@example.com",
name: "Invited User",
sso_enabled: false,
global_role: "observer",
teams: [],
...overrides,
});

describe("UsersTableConfig - combineDataSets", () => {
it("returns 'Active' for a user who logged in recently", () => {
const users = [createMockUser({ last_login_at: daysAgo(1) })];
const [row] = combineDataSets(users, [], 99);
expect(row.status).toBe("Active");
});

it("returns 'Inactive' for a user who hasn't logged in for 30+ days", () => {
const users = [createMockUser({ last_login_at: daysAgo(31) })];
const [row] = combineDataSets(users, [], 99);
expect(row.status).toBe("Inactive");
});

it("falls back to created_at for users who have never logged in", () => {
const stale = createMockUser({
last_login_at: null,
created_at: daysAgo(31),
});
const fresh = createMockUser({
id: 2,
last_login_at: null,
created_at: daysAgo(1),
});
const [staleRow, freshRow] = combineDataSets([stale, fresh], [], 99);
expect(staleRow.status).toBe("Inactive");
expect(freshRow.status).toBe("Active");
});

it("returns 'Active' for an API-only user with recent API activity, even when its token was created long ago", () => {
const users = [
createMockUser({
api_only: true,
last_login_at: daysAgo(100),
last_activity_at: daysAgo(1),
created_at: daysAgo(100),
}),
];
const [row] = combineDataSets(users, [], 99);
expect(row.status).toBe("Active");
});

it("returns 'Inactive' for an API-only user with no recent API activity", () => {
const users = [
createMockUser({
api_only: true,
last_login_at: daysAgo(100),
last_activity_at: daysAgo(45),
created_at: daysAgo(100),
}),
];
const [row] = combineDataSets(users, [], 99);
expect(row.status).toBe("Inactive");
});

it("prefers the most recent of last activity and last login for regular users", () => {
// stale login but a session kept alive by recent activity
const users = [
createMockUser({
last_login_at: daysAgo(40),
last_activity_at: daysAgo(2),
}),
];
const [row] = combineDataSets(users, [], 99);
expect(row.status).toBe("Active");
});

it("returns 'No access' for a user without a role, regardless of last login", () => {
const users = [
createMockUser({
global_role: null,
teams: [],
last_login_at: daysAgo(31),
}),
];
const [row] = combineDataSets(users, [], 99);
expect(row.status).toBe("No access");
});

it("returns 'Invite pending' for invites", () => {
const invites = [createMockInvite()];
const [row] = combineDataSets([], invites, 99);
expect(row.status).toBe("Invite pending");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,45 @@ export interface IUserTableData {
api_only: boolean;
}

/** Number of days without a login/activity after which a user is considered
* inactive. */
const INACTIVE_AFTER_DAYS = 30;

const USER_INACTIVE_TOOLTIP = `Hasn't logged in for ${INACTIVE_AFTER_DAYS}+ days`;
const API_ONLY_INACTIVE_TOOLTIP = `No API activity for ${INACTIVE_AFTER_DAYS}+ days`;

const isInactive = (user: IUser): boolean => {
// A user was last seen at the most recent of its last session activity
// (the signal for API-only users, whose long-lived token session is
// accessed on every request) and its last login. Fall back to created_at
// for users who have never logged in (including accounts that predate
// last login tracking). Server timestamps share the same ISO format, so
// they sort lexicographically.
const lastSeen =
[user.last_activity_at, user.last_login_at]
.filter((date): date is string => !!date)
.sort()
.pop() ?? user.created_at;
if (!lastSeen) {
return false;
}
const msSinceSeen = Date.now() - new Date(lastSeen).getTime();
return msSinceSeen >= INACTIVE_AFTER_DAYS * 24 * 60 * 60 * 1000;
};

const hasNoAccess = (data: IUser | IInvite): boolean =>
data.global_role === null && data.teams.length === 0;

const generateUserStatus = (user: IUser): string => {
if (hasNoAccess(user)) {
return "No access";
}
return isInactive(user) ? "Inactive" : "Active";
};

const generateInviteStatus = (invite: IInvite): string =>
hasNoAccess(invite) ? "No access" : "Invite pending";

// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (
Expand Down Expand Up @@ -182,7 +221,18 @@ const generateTableHeaders = (
),
accessor: "status",
Cell: (cellProps: ICellProps) => (
<StatusIndicator value={cellProps.cell.value} />
<StatusIndicator
value={cellProps.cell.value}
tooltip={
cellProps.cell.value === "Inactive"
? {
tooltipText: cellProps.row.original.api_only
? API_ONLY_INACTIVE_TOOLTIP
: USER_INACTIVE_TOOLTIP,
}
: undefined
}
/>
),
},
{
Expand Down Expand Up @@ -262,15 +312,6 @@ const generateTableHeaders = (
return tableHeaders;
};

const generateStatus = (type: string, data: IUser | IInvite): string => {
const { teams, global_role } = data;
if (global_role === null && teams.length === 0) {
return "No access";
}

return type === "invite" ? "Invite pending" : "Active";
};

const generateActionDropdownOptions = (
isCurrentUser: boolean,
isInvitePending: boolean,
Expand Down Expand Up @@ -336,7 +377,7 @@ const enhanceUserData = (
return users.map((user) => {
return {
name: user.name || DEFAULT_EMPTY_CELL_VALUE,
status: generateStatus("user", user),
status: generateUserStatus(user),
email: user.email,
teams: generateTeam(user.teams, user.global_role),
teamNames: generateTeamNames(user.teams),
Expand All @@ -360,7 +401,7 @@ const enhanceInviteData = (invites: IInvite[]): IUserTableData[] => {
return invites.map((invite) => {
return {
name: invite.name || DEFAULT_EMPTY_CELL_VALUE,
status: generateStatus("invite", invite),
status: generateInviteStatus(invite),
email: invite.email,
teams: generateTeam(invite.teams, invite.global_role),
teamNames: generateTeamNames(invite.teams),
Expand Down
2 changes: 2 additions & 0 deletions frontend/test/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const userStub: IUser = {
role: "Observer",
global_role: null,
api_only: false,
last_login_at: null,
last_activity_at: null,
force_password_reset: false,
gravatar_url: "https://image.com",
sso_enabled: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package tables

import (
"database/sql"

"github.com/pkg/errors"
)

func init() {
MigrationClient.AddMigration(Up_20260713150609, Down_20260713150609)
}

func Up_20260713150609(tx *sql.Tx) error {
if _, err := tx.Exec(`ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP NULL DEFAULT NULL`); err != nil {
return errors.Wrap(err, "add last_login_at to users")
}

// Users' last activity is computed from sessions (MAX(accessed_at) per
// user), which needs an index on user_id.
if _, err := tx.Exec(`ALTER TABLE sessions ADD INDEX idx_sessions_user_id (user_id)`); err != nil {
return errors.Wrap(err, "add user_id index to sessions")
}

// Best-effort backfill from live sessions so existing active users aren't
// reported as never having logged in. A session is created at login, so
// the newest session's created_at is the user's most recent login.
// Sessions are deleted on logout and expiry, so users without a live
// session keep a NULL last_login_at.
// updated_at is explicitly preserved (it has ON UPDATE CURRENT_TIMESTAMP).
if _, err := tx.Exec(`
UPDATE users u
JOIN (
SELECT user_id, MAX(created_at) AS last_session_created_at
FROM sessions
GROUP BY user_id
) s ON s.user_id = u.id
SET u.last_login_at = s.last_session_created_at,
u.updated_at = u.updated_at
`); err != nil {
return errors.Wrap(err, "backfill users.last_login_at from sessions")
}

return nil
}

func Down_20260713150609(tx *sql.Tx) error {
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package tables

import (
"testing"
"time"

"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)

func TestUp_20260713150609(t *testing.T) {
db := applyUpToPrev(t)

// user with live sessions: backfilled from the most recent session
userWithSession := execNoErrLastID(t, db,
`INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`,
"with-session", "with-session@example.com", "p", "s",
)
// user without sessions: last_login_at stays NULL
userWithoutSession := execNoErrLastID(t, db,
`INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`,
"without-session", "without-session@example.com", "p", "s",
)

older := time.Now().UTC().Add(-48 * time.Hour).Truncate(time.Second)
newer := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second)
// accessed_at is more recent than created_at; the backfill must use
// created_at (login time), not accessed_at (last activity).
execNoErr(t, db, "INSERT INTO sessions (user_id, `key`, created_at, accessed_at) VALUES (?, ?, ?, ?)", userWithSession, "session-key-1", older, newer)
execNoErr(t, db, "INSERT INTO sessions (user_id, `key`, created_at, accessed_at) VALUES (?, ?, ?, ?)", userWithSession, "session-key-2", newer, time.Now().UTC())

var updatedAtBefore time.Time
require.NoError(t, sqlx.Get(db, &updatedAtBefore, `SELECT updated_at FROM users WHERE id = ?`, userWithSession))

applyNext(t, db)

var lastLoginAt *time.Time
require.NoError(t, sqlx.Get(db, &lastLoginAt, `SELECT last_login_at FROM users WHERE id = ?`, userWithSession))
require.NotNil(t, lastLoginAt)
require.WithinDuration(t, newer, *lastLoginAt, time.Second)

// the backfill must not bump updated_at (ON UPDATE CURRENT_TIMESTAMP)
var updatedAtAfter time.Time
require.NoError(t, sqlx.Get(db, &updatedAtAfter, `SELECT updated_at FROM users WHERE id = ?`, userWithSession))
require.Equal(t, updatedAtBefore, updatedAtAfter)

lastLoginAt = nil
require.NoError(t, sqlx.Get(db, &lastLoginAt, `SELECT last_login_at FROM users WHERE id = ?`, userWithoutSession))
require.Nil(t, lastLoginAt)
}
8 changes: 5 additions & 3 deletions server/datastore/mysql/schema.sql

Large diffs are not rendered by default.

Loading
Loading