mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 12:46:00 +00:00
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.
This commit is contained in:
@@ -362,6 +362,33 @@ choose your own the first time you sign in.</p>
|
||||
as you. Choose your own password promptly.</li>
|
||||
</ul>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_account_disabled: {
|
||||
subject: 'Your {{team_name}} account has been disabled',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has disabled your Puter account <b>{{username}}</b>. You can no
|
||||
longer sign in to it.</p>
|
||||
<p>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.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_closed: {
|
||||
subject: '{{team_name}} has been closed',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has closed its Puter team, and your account
|
||||
<b>{{username}}</b> has been disabled along with it. You can no longer sign in to
|
||||
it.</p>
|
||||
<p>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.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<unknown>;
|
||||
};
|
||||
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++) {
|
||||
|
||||
@@ -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<PageResult<MemberActivityEntry>> {
|
||||
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<PageResult<MemberActivityEntry>> {
|
||||
const ids = new Set<number>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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. */
|
||||
|
||||
@@ -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<SignInRow[]>}
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?',
|
||||
|
||||
Reference in New Issue
Block a user