From 81d15f412bf80db66749168a4af163c66d02ccd9 Mon Sep 17 00:00:00 2001
From: Juan Castro
Date: Thu, 3 Sep 2026 21:36:08 -0400
Subject: [PATCH] feat: tell a team member what was done to their account
Two halves of the same question -- what did the team do to me, and how do
I find out. One is pull, the other push.
The activity view (PUT-1746) was already user-scoped and already 404'd a
non-member; what was missing is the load-bearing half. A member is told a reset
happened, but a reset only matters alongside who signed in afterwards, so
`SessionStore.listSignIns` merges their own sign-ins into the same stream,
newest first, with a per-stream cursor. Without that row the audit says a
credential was issued and never says whether it was used.
The notices (PUT-1733) cover the two things a member cannot discover for
themselves: their account being disabled, and their team being closed.
Deliberately one each -- closing a team disables every account in it, so
sending both would tell one person twice about one event. Both say plainly that
nothing was deleted; the accounts persist, suspended, holding their files.
Squashed because they are one change to a reviewer: same audience, same
purpose, seven files, overlapping only in TeamService. Neither touches shared
platform code.
---
src/backend/clients/email/templates.ts | 27 ++
.../team/TeamController.http.test.ts | 37 +++
src/backend/services/team/TeamService.test.ts | 233 ++++++++++++++++++
src/backend/services/team/TeamService.ts | 210 +++++++++++++---
src/backend/stores/session/SessionStore.js | 38 +++
src/backend/stores/team/TeamStore.test.ts | 35 +++
src/backend/stores/team/TeamStore.ts | 10 +-
7 files changed, 554 insertions(+), 36 deletions(-)
diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts
index 95c727877..0eaf36730 100644
--- a/src/backend/clients/email/templates.ts
+++ b/src/backend/clients/email/templates.ts
@@ -362,6 +362,33 @@ choose your own the first time you sign in.
as you. Choose your own password promptly.
Sincerely,
+Puter
+ `,
+ },
+ team_account_disabled: {
+ subject: 'Your {{team_name}} account has been disabled',
+ html: `
+Hi there,
+{{team_name}} has disabled your Puter account {{username}}. You can no
+longer sign in to it.
+Nothing has been deleted. Your files, apps and data are as you left them, and
+{{team_name}} can re-enable the account at any time. If you believe this is a
+mistake, ask them.
+Sincerely,
+Puter
+ `,
+ },
+ team_closed: {
+ subject: '{{team_name}} has been closed',
+ html: `
+Hi there,
+{{team_name}} has closed its Puter team, and your account
+{{username}} has been disabled along with it. You can no longer sign in to
+it.
+Nothing has been deleted. Your files and data are still there; closing the
+team does not destroy the accounts it created, and each one would have to be
+deleted on its own request.
+Sincerely,
Puter
`,
},
diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts
index dcaf93189..2c4b061d9 100644
--- a/src/backend/controllers/team/TeamController.http.test.ts
+++ b/src/backend/controllers/team/TeamController.http.test.ts
@@ -346,6 +346,17 @@ describe('team endpoints over HTTP', () => {
expect(body.results[0].recipient).toBe(handle);
});
+ it('gives a non-member 404 on the member view, not an empty page', async () => {
+ const { team } = await makeTeam();
+
+ const res = await call(
+ 'GET',
+ `/teams/${team.uid}/audit/me`,
+ env.users.other.token,
+ );
+ expect(res.status).toBe(404);
+ });
+
// -- the forced-change gate ---------------------------------------
/**
@@ -482,6 +493,32 @@ describe('team endpoints over HTTP', () => {
expect(JSON.stringify(body)).not.toContain(issued);
});
+ it('shows an activated seat its own reset and sign-in over the wire', async () => {
+ const { team } = await makeTeam();
+ const seat = await signedInSeat(team.uid);
+ await changePassword(seat.token, seat.password, 'the-one-i-picked');
+
+ const res = await call('GET', `/teams/${team.uid}/audit/me`, seat.token);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ items: {
+ action: string;
+ username: string | null;
+ ip: string | null;
+ user_agent: string | null;
+ }[];
+ };
+ expect(body.items.map((e) => e.action)).toContain('activate');
+
+ const tell = body.items.find((e) => e.action === 'sign_in');
+ expect(tell?.username).toBe(seat.username);
+ expect(tell?.user_agent).toBe('puter-test-seat');
+ // Only their own; the team owner's entries are not theirs to read.
+ expect(
+ body.items.every((e) => e.username === seat.username),
+ ).toBe(true);
+ });
+
it('refuses a temporary password that was never used in time', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts
index 761e2433a..8a2e5cb72 100644
--- a/src/backend/services/team/TeamService.test.ts
+++ b/src/backend/services/team/TeamService.test.ts
@@ -666,6 +666,135 @@ describe('TeamService', () => {
expect(own[0]).not.toHaveProperty('actor_user_id');
});
+ // -- the member's own view ----------------------------------------
+
+ /**
+ * A sign-in row of the shape `sessions` records for a browser session.
+ * `secondsLater` moves it off the audit rows' timestamp -- within one
+ * second the order of two different sources is arbitrary, and asserting on
+ * it would be asserting on the tie-break rather than on the timeline.
+ */
+ const signIn = async (userId: number, ip: string, secondsLater = 0) => {
+ const created = (await server.stores.session.create(userId, {
+ kind: 'web',
+ last_ip: ip,
+ last_user_agent: 'Chrome/macOS',
+ })) as { uuid: string };
+ if (secondsLater) {
+ await server.clients.db.write(
+ 'UPDATE `sessions` SET `created_at` = `created_at` + ? WHERE `uuid` = ?',
+ [secondsLater, created.uuid],
+ );
+ }
+ return created;
+ };
+
+ it('shows the sign-in between a reset and the member noticing', async () => {
+ const { team } = await makeTeam();
+ const username = `tell_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ await service.resetMemberPassword(team.uid, owner.id, created.userId);
+ await signIn(created.userId, '203.0.113.7', 60);
+
+ const { items } = await service.listOwnAudit(team.uid, created.userId);
+ const tell = items.find((e) => e.action === 'sign_in');
+ expect(tell).toMatchObject({
+ username,
+ actor_username: null,
+ ip: '203.0.113.7',
+ user_agent: 'Chrome/macOS',
+ });
+ // Newest first, so the sign-in sits above the reset that preceded it.
+ expect(items.map((e) => e.action)).toEqual([
+ 'sign_in',
+ 'reset_member_password',
+ 'provision',
+ ]);
+ });
+
+ it('shows no sign-in belonging to anyone else', async () => {
+ const { team } = await makeTeam();
+ const mine = `mine_${Math.random().toString(36).slice(2, 9)}`;
+ const theirs = `thrs_${Math.random().toString(36).slice(2, 9)}`;
+ const a = await service.provisionAccount(team.uid, owner.id, {
+ username: mine,
+ email: `${mine}@test.local`,
+ });
+ const b = await service.provisionAccount(team.uid, owner.id, {
+ username: theirs,
+ email: `${theirs}@test.local`,
+ });
+ await signIn(b.userId, '198.51.100.4');
+
+ const { items } = await service.listOwnAudit(team.uid, a.userId);
+ expect(items.map((e) => e.action)).not.toContain('sign_in');
+ expect(JSON.stringify(items)).not.toContain('198.51.100.4');
+ expect(JSON.stringify(items)).not.toContain(theirs);
+ });
+
+ it('counts only browser sign-ins, not credentials derived from one', async () => {
+ const { team } = await makeTeam();
+ const username = `drv_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ await server.stores.session.create(created.userId, {
+ kind: 'access_token',
+ last_ip: '198.51.100.77',
+ });
+
+ const { items } = await service.listOwnAudit(team.uid, created.userId);
+ expect(items.map((e) => e.action)).not.toContain('sign_in');
+ });
+
+ it('leaves the team audit free of sign-ins', async () => {
+ const { team } = await makeTeam();
+ const username = `noss_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ await signIn(created.userId, '192.0.2.9');
+
+ const { items } = await service.listAudit(team.uid, owner.id);
+ expect(items.map((e) => e.action)).not.toContain('sign_in');
+ });
+
+ it('pages both streams rather than dropping one of them', async () => {
+ const { team } = await makeTeam();
+ const username = `pgm_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ await service.resetMemberPassword(team.uid, owner.id, created.userId);
+ await signIn(created.userId, '203.0.113.1');
+ await signIn(created.userId, '203.0.113.2');
+
+ // Four entries: provision, reset, and two sign-ins.
+ const seen: string[] = [];
+ let cursor: string | undefined;
+ for (let page = 0; page < 8; page++) {
+ const result = await service.listOwnAudit(team.uid, created.userId, {
+ limit: 1,
+ cursor,
+ });
+ seen.push(...result.items.map((e) => e.action));
+ cursor = result.cursor;
+ if (!cursor) break;
+ }
+ expect(seen.sort()).toEqual([
+ 'provision',
+ 'reset_member_password',
+ 'sign_in',
+ 'sign_in',
+ ]);
+ });
+
it('keeps the audit from a member who is not the owner', async () => {
const { team, member } = await makeTeam();
await expect(
@@ -706,6 +835,110 @@ describe('TeamService', () => {
expect(Boolean((await suspensionOf(owner.id)).suspended)).toBe(false);
});
+ it('keeps memberships and group grants behind the deleted_at', async () => {
+ const { team } = await makeTeam();
+ const username = `grnt_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ await server.clients.db.write(
+ 'INSERT INTO `user_to_group_permissions` ' +
+ '(`user_id`, `group_id`, `permission`) VALUES (?, ?, ?)',
+ [owner.id, team.id, 'fs:some-uid:read'],
+ );
+
+ await service.deleteTeam(team.uid, owner.id);
+
+ // A hard DELETE would cascade both of these away with no audit row.
+ const members = (await server.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `jct_user_group` WHERE `group_id` = ?',
+ [team.id],
+ )) as { n: number }[];
+ expect(Number(members[0].n)).toBeGreaterThanOrEqual(2);
+
+ const grants = (await server.clients.db.read(
+ 'SELECT COUNT(*) AS n FROM `user_to_group_permissions` WHERE `group_id` = ?',
+ [team.id],
+ )) as { n: number }[];
+ expect(Number(grants[0].n)).toBe(1);
+
+ // And the account itself is disabled, not destroyed.
+ expect(await server.stores.user.getById(created.userId)).toBeTruthy();
+ });
+
+ // -- notifications -------------------------------------------------
+
+ /** Captures what would go out, without standing up a transport. */
+ const captureMail = () => {
+ const sent: { to: string; subject: string }[] = [];
+ const client = server.clients.email as unknown as {
+ sendRaw: (o: { to?: string; subject?: string }) => Promise;
+ };
+ const original = client.sendRaw.bind(client);
+ client.sendRaw = async (options) => {
+ sent.push({
+ to: String(options.to ?? ''),
+ subject: String(options.subject ?? ''),
+ });
+ return null;
+ };
+ return { sent, restore: () => (client.sendRaw = original) };
+ };
+
+ it('tells a member their account was disabled', async () => {
+ const { team } = await makeTeam();
+ const username = `dis_${Math.random().toString(36).slice(2, 9)}`;
+ const created = await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+
+ const mail = captureMail();
+ try {
+ await service.disableMember(team.uid, owner.id, created.userId);
+ } finally {
+ mail.restore();
+ }
+
+ expect(mail.sent).toHaveLength(1);
+ expect(mail.sent[0].to).toBe(`${username}@test.local`);
+ expect(mail.sent[0].subject).toContain('disabled');
+ });
+
+ it('tells every member the team closed, and tells them once', async () => {
+ const { team } = await makeTeam();
+ const names = [
+ `cl1_${Math.random().toString(36).slice(2, 9)}`,
+ `cl2_${Math.random().toString(36).slice(2, 9)}`,
+ ];
+ for (const username of names) {
+ await service.provisionAccount(team.uid, owner.id, {
+ username,
+ email: `${username}@test.local`,
+ });
+ }
+
+ const mail = captureMail();
+ try {
+ await service.deleteTeam(team.uid, owner.id);
+ } finally {
+ mail.restore();
+ }
+
+ // The closure notice covers the disabling; two notices would be spam.
+ for (const username of names) {
+ const forMember = mail.sent.filter(
+ (m) => m.to === `${username}@test.local`,
+ );
+ expect(forMember).toHaveLength(1);
+ expect(forMember[0].subject).toContain('closed');
+ }
+ const ownerRow = await server.stores.user.getById(owner.id);
+ // The owner account is not the team's to close.
+ expect(mail.sent.map((m) => m.to)).not.toContain(ownerRow!.email);
+ });
+
it('pages the audit rather than truncating it', async () => {
const { team } = await makeTeam();
for (let i = 0; i < 2; i++) {
diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts
index dce94c585..0743fad38 100644
--- a/src/backend/services/team/TeamService.ts
+++ b/src/backend/services/team/TeamService.ts
@@ -25,18 +25,29 @@ import {
USERNAME_MAX_LENGTH,
USERNAME_REGEX,
} from '../../controllers/auth/AuthController.js';
+import type { EmailTemplateName } from '../../clients/email/templates.js';
import type {
EventMap,
TeamBillingContext,
TeamBillingEvent,
} from '../../clients/event/types';
import { HttpError } from '../../core/http/HttpError.js';
-import { checkHandle } from '../../stores/team/TeamStore.js';
+import {
+ AUDIT_PAGE_CAP,
+ AUDIT_PAGE_SIZE,
+ checkHandle,
+} from '../../stores/team/TeamStore.js';
import type {
TeamAuditRow,
TeamMemberRow,
TeamRow,
} from '../../stores/team/TeamStore';
+import {
+ decodeCursor,
+ encodeCursor,
+ normalizeLimit,
+ type PageResult,
+} from '../../util/pagination.js';
import type { UserRow } from '../../stores/user/UserStore';
import { cleanEmail } from '../../util/email.js';
import {
@@ -67,6 +78,34 @@ const CAP_LOCK_TTL_SECONDS = 10;
export const AUDIT_RESET_PASSWORD = 'reset_member_password';
export const AUDIT_ACTIVATE = 'activate';
+/** Not an audit row: synthesised from `sessions` for the member's own view. */
+export const SIGN_IN_ACTION = 'sign_in';
+
+/** One line of a member's activity, whether recorded or a sign-in. */
+export interface MemberActivityEntry {
+ action: string;
+ reason: string | null;
+ /** Unix seconds, so the two sources sort on one axis on every dialect. */
+ created_at: number;
+ username: string | null;
+ actor_username: string | null;
+ ip: string | null;
+ user_agent: string | null;
+}
+
+/** `sessions` stores unix seconds; audit rows a timestamp the driver shapes. */
+const epochSeconds = (value: unknown): number => {
+ if (value instanceof Date) return Math.floor(value.getTime() / 1000);
+ if (typeof value === 'number') return Math.floor(value);
+ const text = String(value ?? '');
+ // sqlite returns UTC 'YYYY-MM-DD HH:MM:SS', which Date.parse reads as local.
+ const iso = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u.test(text)
+ ? `${text.replace(' ', 'T')}Z`
+ : text;
+ const parsed = Date.parse(iso);
+ return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
+};
+
export class TeamService extends PuterService {
// -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ----
@@ -390,6 +429,14 @@ export class TeamService extends PuterService {
username: member.username,
held_bytes: held,
});
+
+ // The team notice covers the disabling, so a member is
+ // told once rather than twice about the same event.
+ await this.#notifyMember(
+ member.user_id,
+ 'team_closed',
+ team,
+ );
}
if (!page.cursor) break;
page = await this.stores.team.listMembers(teamUid, {
@@ -424,16 +471,95 @@ export class TeamService extends PuterService {
);
}
- /** The caller's own entries; the only reader who is not the actor. */
+ /**
+ * The caller's own entries, interleaved with sign-ins to their account. The
+ * only reader who is not the actor, and the only place a sign-in between an
+ * administrator's reset and the member's own password change becomes
+ * visible to the person it concerns.
+ */
async listOwnAudit(
teamUid: string,
actorUserId: number,
opts: { limit?: unknown; cursor?: string } = {},
- ) {
+ ): Promise> {
const team = await this.requireMembership(teamUid, actorUserId);
- return this.#withUsernames(
- await this.stores.team.listAuditForUser(team.id, actorUserId, opts),
+ const limit =
+ normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ??
+ AUDIT_PAGE_SIZE;
+ const cursor =
+ decodeCursor(opts.cursor, 'member activity cursor') ?? {};
+ const fromAudit = typeof cursor.a === 'number' ? cursor.a : undefined;
+ const fromSignIn = typeof cursor.s === 'number' ? cursor.s : null;
+
+ const audit = await this.stores.team.listAuditForUser(
+ team.id,
+ actorUserId,
+ {
+ limit,
+ cursor:
+ fromAudit === undefined
+ ? undefined
+ : encodeCursor({ id: fromAudit }),
+ },
);
+ // One past the limit, so a page that consumes no sign-in still knows
+ // whether any remain.
+ const signIns = await this.stores.session.listSignIns(actorUserId, {
+ limit: limit + 1,
+ beforeId: fromSignIn,
+ });
+
+ const named = await this.#withUsernames(audit);
+ const self =
+ (await this.stores.user.getById(actorUserId))?.username ?? null;
+ const merged = [
+ ...named.items.map((entry, i) => ({
+ entry,
+ stream: 'a' as const,
+ id: audit.items[i].id,
+ })),
+ ...signIns.slice(0, limit).map((row) => ({
+ entry: {
+ action: SIGN_IN_ACTION,
+ reason: null,
+ created_at: epochSeconds(row.created_at),
+ username: self,
+ actor_username: null,
+ ip: row.last_ip,
+ user_agent: row.last_user_agent,
+ } as MemberActivityEntry,
+ stream: 's' as const,
+ id: row.id,
+ })),
+ ].sort(
+ (x, y) =>
+ y.entry.created_at - x.entry.created_at ||
+ x.stream.localeCompare(y.stream) ||
+ y.id - x.id,
+ );
+
+ const page = merged.slice(0, limit);
+ const more =
+ merged.length > limit || !!audit.cursor || signIns.length > limit;
+ // A stream that contributed nothing keeps its old position: everything
+ // it still holds is older than this page, so it resumes where it was.
+ const next = {
+ ...(page.some((row) => row.stream === 'a')
+ ? { a: page.findLast((row) => row.stream === 'a')!.id }
+ : fromAudit === undefined
+ ? {}
+ : { a: fromAudit }),
+ ...(page.some((row) => row.stream === 's')
+ ? { s: page.findLast((row) => row.stream === 's')!.id }
+ : fromSignIn === null
+ ? {}
+ : { s: fromSignIn }),
+ };
+
+ return {
+ items: page.map((row) => row.entry),
+ ...(more ? { cursor: encodeCursor(next) } : {}),
+ };
}
/** Resolves a team the caller owns, soft-deleted or not. */
@@ -455,7 +581,10 @@ export class TeamService extends PuterService {
}
/** Internal user ids never reach the wire, as `toClientTeam` does for `id`. */
- async #withUsernames(page: { items: TeamAuditRow[]; cursor?: string }) {
+ async #withUsernames(page: {
+ items: TeamAuditRow[];
+ cursor?: string;
+ }): Promise> {
const ids = new Set();
for (const row of page.items) {
ids.add(row.user_id_keep);
@@ -466,12 +595,15 @@ export class TeamService extends PuterService {
id === null ? null : (users.get(id)?.username ?? null);
return {
- items: page.items.map((row) => ({
+ items: page.items.map((row): MemberActivityEntry => ({
action: row.action,
reason: row.reason,
- created_at: row.created_at,
+ created_at: epochSeconds(row.created_at),
username: name(row.user_id_keep),
actor_username: name(row.actor_user_id),
+ // Only a sign-in carries these; the shape stays uniform.
+ ip: null,
+ user_agent: null,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
};
@@ -602,7 +734,7 @@ export class TeamService extends PuterService {
// Returned once; forced change on first use is what bounds it.
const temporaryPassword = await this.#issueTemporaryPassword(user.id);
- await this.#notifyAccountCreated(user, team);
+ await this.#notifyUser(user, 'team_account_created', team);
// Last: the seat is only chargeable once it exists and can be used.
this.#emitBilling('team.account.created', {
@@ -645,7 +777,7 @@ export class TeamService extends PuterService {
});
const temporaryPassword =
await this.#issueTemporaryPassword(targetUserId);
- await this.#notifyAccountCreated(user, team);
+ await this.#notifyUser(user, 'team_account_created', team);
return { temporaryPassword };
}
@@ -673,7 +805,7 @@ export class TeamService extends PuterService {
await this.#issueTemporaryPassword(targetUserId);
// 2FA is deliberately untouched: a reset alone is not takeover.
await this.#dropSessions(targetUserId);
- await this.#notifyPasswordReset(user, team);
+ await this.#notifyUser(user, 'team_password_reset', team);
return { temporaryPassword };
}
@@ -725,38 +857,43 @@ export class TeamService extends PuterService {
return temporaryPassword;
}
- /** Carries no credential -- the administrator delivers that out of band. */
- async #notifyPasswordReset(user: UserRow, team: TeamRow): Promise {
- if (!this.clients.email || !user.email) return;
+ /**
+ * A notice about something the team did to a member's account. It
+ * carries no credential, so delivery is best effort -- nothing the caller
+ * did depends on it arriving, and an address the administrator supplied may
+ * not even reach its holder.
+ */
+ async #notifyUser(
+ user: UserRow | null | undefined,
+ template: EmailTemplateName,
+ team: TeamRow,
+ ): Promise {
+ if (!this.clients.email || !user?.email) return;
try {
- await this.clients.email.send(user.email, 'team_password_reset', {
+ const sent = await this.clients.email.send(user.email, template, {
username: user.username,
team_name: team.name ?? 'Your team',
});
+ // `sendRaw` returns null with no transport rather than throwing.
+ if (sent === null) {
+ console.warn(`[team] no email transport for ${template}`);
+ }
} catch (e) {
- console.warn('[team-reset] notice failed:', e);
+ console.warn(`[team] ${template} notice failed:`, e);
}
}
- /** A notice only -- it carries no credential, so delivery is best effort. */
- async #notifyAccountCreated(user: UserRow, team: TeamRow): Promise {
- if (!this.clients.email || !user.email) return;
- try {
- const sent = await this.clients.email.send(
- user.email,
- 'team_account_created',
- {
- username: user.username,
- team_name: team.name ?? 'Your team',
- },
- );
- // `sendRaw` returns null with no transport rather than throwing.
- if (sent === null) {
- console.warn('[team-provision] no email transport configured');
- }
- } catch (e) {
- console.warn('[team-provision] notice failed:', e);
- }
+ /** The same notice, for a caller holding only the member's id. */
+ async #notifyMember(
+ userId: number,
+ template: EmailTemplateName,
+ team: TeamRow,
+ ): Promise {
+ await this.#notifyUser(
+ await this.stores.user.getById(userId),
+ template,
+ team,
+ );
}
// -- Disable and re-enable ---- the whole of offboarding ------------
@@ -799,6 +936,9 @@ export class TeamService extends PuterService {
username: membership.username,
held_bytes: held,
});
+
+ // Their sessions are gone, so email is the only channel left.
+ await this.#notifyMember(targetUserId, 'team_account_disabled', team);
}
/** Nothing was destroyed, so the account returns as it was. */
diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js
index 05bba36d5..8e5aa8e63 100644
--- a/src/backend/stores/session/SessionStore.js
+++ b/src/backend/stores/session/SessionStore.js
@@ -128,6 +128,44 @@ export class SessionStore extends PuterStore {
return rows.map((r) => this.#normalizeRow(r)).filter(Boolean);
}
+ /**
+ * @typedef {object} SignInRow
+ * @property {number} id
+ * @property {number} created_at Unix seconds.
+ * @property {string | null} last_ip
+ * @property {string | null} last_user_agent
+ */
+
+ /**
+ * Interactive sign-ins for a user, newest first, for the account's own
+ * activity view. Revoked rows are included -- a session someone opened and
+ * closed is exactly what the reader is looking for. Derived kinds (app,
+ * access token, asset, worker) are not sign-ins and stay out.
+ *
+ * Uncached and keyset-paginated on `id`: the caller merges this with
+ * another stream, so it asks for one page at a time rather than the whole
+ * history.
+ *
+ * @param {number} userId
+ * @param {{ limit: number; beforeId?: number | null }} opts
+ * @returns {Promise}
+ */
+ async listSignIns(userId, { limit, beforeId = null }) {
+ const rows = await this.clients.db.read(
+ 'SELECT `id`, `created_at`, `last_ip`, `last_user_agent` FROM `sessions` ' +
+ "WHERE `user_id` = ? AND `kind` = 'web'" +
+ (beforeId === null ? '' : ' AND `id` < ?') +
+ ' ORDER BY `id` DESC LIMIT ?',
+ beforeId === null ? [userId, limit] : [userId, beforeId, limit],
+ );
+ return rows.map((row) => ({
+ id: Number(row.id),
+ created_at: Number(row.created_at ?? 0),
+ last_ip: row.last_ip ?? null,
+ last_user_agent: row.last_user_agent ?? null,
+ }));
+ }
+
/**
* Create a new session row.
*
diff --git a/src/backend/stores/team/TeamStore.test.ts b/src/backend/stores/team/TeamStore.test.ts
index 6bb89b237..f970028e2 100644
--- a/src/backend/stores/team/TeamStore.test.ts
+++ b/src/backend/stores/team/TeamStore.test.ts
@@ -501,4 +501,39 @@ describe('TeamStore', () => {
expect(paged.items).toHaveLength(2);
expect(paged.cursor).toBeTruthy();
});
+ it('returns audit timestamps as unix seconds, not a dialect datetime', async () => {
+ const team = await store.create({
+ ownerUserId: owner.id,
+ name: 'Stamps',
+ handle: freeHandle(),
+ });
+ const member = await makeUser();
+ await store.addMember(team.uid, member.id, { orgOwned: true });
+ await store.appendAudit({
+ teamId: team.id,
+ userId: member.id,
+ actorUserId: owner.id,
+ action: 'provision',
+ });
+
+ const page = await store.listAuditForUser(team.id, member.id);
+ const row = page.items[0];
+
+ // Merged with session rows, which are unix seconds; a dialect
+ // datetime sorts the two streams apart.
+ expect(typeof row.created_at).toBe('number');
+
+ // Against the database's own clock, not the host's: the postgres test
+ // engine is an emulator whose VM clock sits years off wall time.
+ const [{ db_now: dbNow }] = (await server.clients.db.read(
+ server.clients.db.case({
+ postgres:
+ 'SELECT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP::timestamp)::bigint AS db_now',
+ mysql: 'SELECT UNIX_TIMESTAMP() AS db_now',
+ otherwise: "SELECT CAST(strftime('%s','now') AS INTEGER) AS db_now",
+ }),
+ [],
+ )) as Array<{ db_now: number }>;
+ expect(Math.abs(row.created_at - Number(dbNow))).toBeLessThan(300);
+ });
});
diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts
index d75331756..a7c04846a 100644
--- a/src/backend/stores/team/TeamStore.ts
+++ b/src/backend/stores/team/TeamStore.ts
@@ -627,8 +627,16 @@ export class TeamStore extends PuterStore {
const page = decodeCursor(opts.cursor, 'team audit cursor');
const before = typeof page?.id === 'number' ? page.id : null;
+ // Unix seconds in SQL: the mysql driver reads a stored UTC datetime
+ // as local, which shifts every row away from the sign-ins.
+ const epoch = this.clients.db.case({
+ postgres: "EXTRACT(EPOCH FROM `created_at`)::bigint",
+ mysql: 'UNIX_TIMESTAMP(`created_at`)',
+ otherwise: "CAST(strftime('%s', `created_at`) AS INTEGER)",
+ });
const rows = (await this.clients.db.read(
- 'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, `created_at` ' +
+ 'SELECT `id`, `user_id_keep`, `actor_user_id`, `action`, `reason`, ' +
+ `${epoch} AS \`created_at\` ` +
`FROM \`audit_team_membership\` WHERE ${where}` +
(before === null ? '' : ' AND `id` < ?') +
' ORDER BY `id` DESC LIMIT ?',