Merge pull request #3745 from HeyPuter/juancastro/put-1736-team-directory

🏗️ PUT-1736: a team directory apps can read, once the team opens it
This commit is contained in:
Juan Fernando Castro
2026-09-09 16:09:03 -04:00
committed by GitHub
27 changed files with 548 additions and 86 deletions
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 78;
const CURRENT_SCHEMA_VERSION = 79;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -112,6 +112,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[75, ['0080_kv-share-handles.sql']],
[76, ['0081_event-subscriptions-indexes.sql']],
[77, ['0082_temp-password-expiry.sql']],
[78, ['0083_team-directory.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,21 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0083_team-directory.sql for the column rationale.
-- No per-file applied-state tracking, so the column goes through _puter_add_col.
CALL _puter_add_col('group', 'directory_enabled', '`directory_enabled` tinyint(1) NOT NULL DEFAULT 0');
@@ -0,0 +1,21 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0083_team-directory.sql for the column rationale.
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS directory_enabled smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,22 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Whether apps acting for a member may read this team's member list.
-- Members can already enumerate each other through `/teams/:uid/members`,
-- which requires a user actor; this opens the same names to an app actor,
-- so it is off until the team owner turns it on.
ALTER TABLE `group` ADD COLUMN `directory_enabled` INTEGER NOT NULL DEFAULT 0;
+3 -3
View File
@@ -36,9 +36,9 @@ type GuiEvent<R = Record<string, unknown>> = {
type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string };
/**
* Who pays, and for which team. Deliberately no payment identity: the
* payer's row is alive at emit time, so a consumer resolves it when it acts --
* a snapshot taken here would be stale, and two events racing on it would each
* Who pays, and for which team. Deliberately no payment identity: the payer's
* row is alive at emit time, so a consumer resolves it when it acts -- a
* snapshot taken here would be stale, and two events racing on it would each
* create their own customer.
*/
export type TeamBillingContext = {
@@ -172,6 +172,49 @@ describe('team endpoints over HTTP', () => {
).toBeFalsy();
});
// -- the directory ------------------------------------------------
it('keeps the directory shut until the team opts in', async () => {
const { team } = await makeTeam();
const before = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.user.token,
);
// 404, not 403: whether this is on is not an app's to probe for.
expect(before.status).toBe(404);
const on = await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
directory_enabled: true,
});
expect(on.status).toBe(200);
expect(await on.json()).toMatchObject({ directory_enabled: true });
const after = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.user.token,
);
expect(after.status).toBe(200);
const body = (await after.json()) as { items: { username: string }[] };
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
});
it('refuses the directory to someone outside the team', async () => {
const { team } = await makeTeam();
await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
directory_enabled: true,
});
const res = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.other.token,
);
expect(res.status).toBe(404);
});
it('lists members with org_owned distinguishing the owner', async () => {
const { team, memberUsername } = await makeTeam();
+39 -7
View File
@@ -65,6 +65,7 @@ const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
handle: team.handle,
is_owner: isOwner,
created_at: team.created_at,
directory_enabled: Number(team.directory_enabled) === 1,
});
@Controller('/teams')
@@ -138,17 +139,24 @@ export class TeamController extends PuterController {
await this.services.team.requireOwner(this.#param(req, 'uid'), userId);
const body = this.#body(req);
const changes: { name?: string; handle?: string | null } = {};
const changes: {
name?: string;
handle?: string | null;
directoryEnabled?: boolean;
} = {};
if (body.name !== undefined)
changes.name = this.#requireString(body.name, 'name');
if (body.handle !== undefined)
changes.handle = body.handle === null ? null : String(body.handle);
if (body.directory_enabled !== undefined)
changes.directoryEnabled = body.directory_enabled === true;
const team = await this.stores.team.update(
// Through the service: turning the directory on writes an audit row.
const team = await this.services.team.updateTeam(
this.#param(req, 'uid'),
userId,
changes,
);
if (!team) throw this.#notFound();
res.json(toClientTeam(team, true));
}
@@ -160,10 +168,7 @@ export class TeamController extends PuterController {
})
async deleteTeam(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.services.team.deleteTeam(
this.#param(req, 'uid'),
userId,
);
await this.services.team.deleteTeam(this.#param(req, 'uid'), userId);
res.json({ success: true });
}
@@ -202,6 +207,33 @@ export class TeamController extends PuterController {
});
}
/**
* The only team route that admits an app actor. It discloses nothing a
* colleague cannot already read through `/members`, and the team has to
* have opted in, so an app cannot enumerate a team by default.
*/
@Get('/:uid/directory', {
subdomain: 'api',
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listDirectory(req: Request, res: Response): Promise<void> {
// The membership is the person's, never the app's.
const userId = this.#requireUserId(req);
const page = await this.services.team.listDirectory(
this.#param(req, 'uid'),
userId,
{
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
},
);
res.json(page);
}
@Post('/:uid/members', {
subdomain: 'api',
requireUserActor: true,
@@ -287,7 +287,6 @@ export class ShareNotificationService extends PuterService {
);
}
// Each recipient fails alone: one refused send must not cost the next
// person their notification. Failures are logged, never thrown.
await Promise.allSettled(
@@ -329,9 +328,9 @@ export class ShareNotificationService extends PuterService {
}
/**
* Who each share announces to. A user share is its own holder; a team
* share is every live member except the issuer, expanded at announcement
* time so the grant stays one row and only the telling fans out.
* Who each share announces to. A user share is its own holder; a team share
* is every live member except the issuer, expanded at announcement time so
* the grant stays one row and only the telling fans out.
*
* A member who joins later resolves the grant through the scan but is never
* told: access follows the team, announcements describe a moment.
+36 -35
View File
@@ -271,8 +271,9 @@ const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
const BLOCK_ALL_SHARES_KEY = 'blockAllShares';
/** Whether this account refuses shares from everyone. */
export const blocksAllShares = (user: Pick<UserRow, 'metadata'> | null): boolean =>
Boolean(user?.metadata?.[BLOCK_ALL_SHARES_KEY]);
export const blocksAllShares = (
user: Pick<UserRow, 'metadata'> | null,
): boolean => Boolean(user?.metadata?.[BLOCK_ALL_SHARES_KEY]);
/**
* What a share recipient's browser is told about someone else's entry.
@@ -886,7 +887,9 @@ export class ShareService extends PuterService {
if (resolved.kind === 'team') {
// Handles are public, so without this anyone could push files into
// every member's inbox by naming one.
if (!(await this.stores.team.isMember(resolved.team.uid, issuerId))) {
if (
!(await this.stores.team.isMember(resolved.team.uid, issuerId))
) {
throw new HttpError(404, 'Team does not exist', {
legacyCode: 'team_not_found',
});
@@ -1684,9 +1687,7 @@ export class ShareService extends PuterService {
.filter((row) => row.holder_group_id)
.map((row) => Number(row.holder_group_id)),
),
].map((id) =>
this.stores.team.getByIdIncludingDeleted(id),
),
].map((id) => this.stores.team.getByIdIncludingDeleted(id)),
)
)
.filter((team): team is TeamRow => team !== null)
@@ -2114,9 +2115,7 @@ export class ShareService extends PuterService {
await Promise.all(
[
...new Set(
groupRows.map((row) =>
Number(row.holder_group_id),
),
groupRows.map((row) => Number(row.holder_group_id)),
),
].map((id) => this.stores.team.getByIdIncludingDeleted(id)),
)
@@ -2560,27 +2559,28 @@ export class ShareService extends PuterService {
onlyIssuer?: number,
): Promise<{ revoked: number }> {
const isOwner = entry.userId === issuerId;
const issuers = onlyIssuer !== undefined
? [onlyIssuer]
: isOwner
? [
...new Set(
(
await this.stores.share.listGroupSharesByFsentry(
entry.id,
)
)
.filter(
(row: { holder_group_id: number }) =>
Number(row.holder_group_id) === team.id,
)
.map((row: { issuer_user_id: number }) =>
Number(row.issuer_user_id),
),
),
issuerId,
]
: [issuerId];
const issuers =
onlyIssuer !== undefined
? [onlyIssuer]
: isOwner
? [
...new Set(
(
await this.stores.share.listGroupSharesByFsentry(
entry.id,
)
)
.filter(
(row: { holder_group_id: number }) =>
Number(row.holder_group_id) === team.id,
)
.map((row: { issuer_user_id: number }) =>
Number(row.issuer_user_id),
),
),
issuerId,
]
: [issuerId];
// Authority is the caller's throughout, as on the user-to-user path:
// impersonating a delegate who has since lost it throws 403 and aborts
@@ -2723,7 +2723,11 @@ export class ShareService extends PuterService {
entryMeta?: boolean;
provenance?: boolean;
holderUsername?: string | null;
holderTeam?: { uid: string; name: string | null; handle: string | null };
holderTeam?: {
uid: string;
name: string | null;
handle: string | null;
};
via?: string | null;
path?: string;
} = {},
@@ -2931,10 +2935,7 @@ export class ShareService extends PuterService {
* Who the share is for. An unconfirmed address resolves to an invite rather
* than a failure; a username cannot be invited, there is nothing to reach.
*/
/**
* Null when the caller named no team; throws when they named a bad
* one.
*/
/** Null when the caller named no team; throws when they named a bad one. */
async #resolveTeamRecipient(
recipient: ShareRecipient,
): Promise<TeamRow | null> {
@@ -319,6 +319,58 @@ describe('team billing events', () => {
expect(mine.map((r) => r.action)).toContain('delete_account');
});
// -- the directory --------------------------------------------------
it('offers only members who have actually taken up their account', async () => {
const team = await makeTeam();
const seat = await provision(team);
await service.updateTeam(team.uid, owner.id, {
directoryEnabled: true,
});
const unactivated = await service.listDirectory(team.uid, owner.id);
// Provisioned holds the temporary password from birth, so this is the
// forced-change flag, not the password, deciding.
expect(unactivated.items.map((m) => m.username)).not.toContain(seat.username);
await server.stores.user.update(seat.userId, {
requires_password_change: 0,
});
await server.stores.user.invalidateById(seat.userId);
const activated = await service.listDirectory(team.uid, owner.id);
expect(activated.items.map((m) => m.username)).toContain(seat.username);
await service.disableMember(team.uid, owner.id, seat.userId);
const suspended = await service.listDirectory(team.uid, owner.id);
expect(suspended.items.map((m) => m.username)).not.toContain(seat.username);
});
it('records the directory being opened, but not a repeat of the same value', async () => {
const team = await makeTeam();
const directoryRows = async () => {
const page = await server.stores.team.listAudit(team.id);
return page.items
.map((r) => String(r.action))
.filter((a) => a.startsWith('directory_'));
};
await service.updateTeam(team.uid, owner.id, { directoryEnabled: true });
expect(await directoryRows()).toEqual(['directory_enabled']);
await service.updateTeam(team.uid, owner.id, { directoryEnabled: true });
expect(await directoryRows()).toEqual(['directory_enabled']);
await service.updateTeam(team.uid, owner.id, { directoryEnabled: false });
expect(await directoryRows()).toEqual(['directory_disabled', 'directory_enabled']);
});
it('refuses the directory while the team has not opted in', async () => {
const team = await makeTeam();
await expect(
service.listDirectory(team.uid, owner.id),
).rejects.toMatchObject({ statusCode: 404 });
});
it('says nothing about an account no team pays for', async () => {
const outsider = await makeUser();
seen.length = 0;
+64 -15
View File
@@ -81,6 +81,10 @@ export const AUDIT_ACTIVATE = 'activate';
/** Written before the row it names goes; `_keep` is what preserves it. */
export const AUDIT_DELETE_ACCOUNT = 'delete_account';
/** A disclosure change, so it is recorded like anything else the team does. */
export const AUDIT_DIRECTORY_ON = 'directory_enabled';
export const AUDIT_DIRECTORY_OFF = 'directory_disabled';
/** Not an audit row: synthesised from `sessions` for the member's own view. */
export const SIGN_IN_ACTION = 'sign_in';
@@ -222,7 +226,7 @@ export class TeamService extends PuterService {
// -- Caps ---- bounds, not billing; the charge is out of repo ---------
/** Live teams one user may own. */
#workspaceCap(): number {
#teamCap(): number {
const n = Number(this.config.max_teams_per_user);
return Number.isFinite(n) && n > 0 ? n : 1;
}
@@ -317,9 +321,13 @@ export class TeamService extends PuterService {
async updateTeam(
teamUid: string,
actorUserId: number,
changes: { name?: string; handle?: string | null },
changes: {
name?: string;
handle?: string | null;
directoryEnabled?: boolean;
},
): Promise<TeamRow> {
await this.requireOwner(teamUid, actorUserId);
const before = await this.requireOwner(teamUid, actorUserId);
if (changes.handle) await this.assertHandleUsable(changes.handle);
const team = await this.#asHttpErrors(() =>
@@ -330,9 +338,55 @@ export class TeamService extends PuterService {
legacyCode: 'team_not_found',
});
}
// Recorded because it changes who can read the member list, which is
// not something a team should be able to alter silently.
const was = Number(before.directory_enabled) === 1;
if (
changes.directoryEnabled !== undefined &&
changes.directoryEnabled !== was
) {
await this.stores.team.appendAudit({
teamId: team.id,
userId: actorUserId,
actorUserId,
action: changes.directoryEnabled
? AUDIT_DIRECTORY_ON
: AUDIT_DIRECTORY_OFF,
});
}
return team;
}
/**
* The member list as an app may read it. Unlike every other team route this
* admits an app actor, so the team has to have opted in and the page
* carries only what a colleague already sees.
*/
async listDirectory(
teamUid: string,
actorUserId: number,
opts: { limit?: unknown; cursor?: string } = {},
): Promise<PageResult<{ username: string; uuid: string }>> {
const team = await this.requireMembership(teamUid, actorUserId);
// 404 rather than 403: whether a team has this on is itself
// something an app should not be able to probe for.
if (Number(team.directory_enabled) !== 1) {
throw new HttpError(404, 'Team not found', {
legacyCode: 'team_not_found',
});
}
const page = await this.stores.team.listDirectory(teamUid, opts);
return {
items: page.items.map((m) => ({
username: m.username,
uuid: m.uuid,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
};
}
/** Creates a team and admits its creator as the team owner. */
async createTeam(
ownerUserId: number,
@@ -348,7 +402,7 @@ export class TeamService extends PuterService {
input: { name: string; handle?: string | null },
): Promise<TeamRow> {
// First: a capped user should hear that, not that the name was taken.
const cap = this.#workspaceCap();
const cap = this.#teamCap();
if ((await this.stores.team.countOwned(ownerUserId)) >= cap) {
throw new HttpError(
409,
@@ -435,11 +489,7 @@ export class TeamService extends PuterService {
// 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,
);
await this.#notifyMember(member.user_id, 'team_closed', team);
}
if (!page.cursor) break;
page = await this.stores.team.listMembers(teamUid, {
@@ -786,8 +836,7 @@ export class TeamService extends PuterService {
/**
* Takes a live account back with a fresh temporary password. The one route
* from a team to member data, and the answer to a locked-out
* employee.
* from a team to member data, and the answer to a locked-out employee.
*/
async resetMemberPassword(
teamUid: string,
@@ -861,10 +910,10 @@ export class TeamService extends PuterService {
}
/**
* 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.
* 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,
+6 -6
View File
@@ -431,9 +431,9 @@ export class ShareStore extends PuterStore {
}
/**
* Team shares on one node. Neither `listByFsentry` (holder rows) nor
* the invite feed matches them, so without this the share dialog shows
* nothing for a file shared with a team.
* Team shares on one node. Neither `listByFsentry` (holder rows) nor the
* invite feed matches them, so without this the share dialog shows nothing
* for a file shared with a team.
*
* @param {number} fsentryId
*/
@@ -453,9 +453,9 @@ export class ShareStore extends PuterStore {
* Unclaimed invites on one node, whoever sent them. What someone managing
* the node needs to see who has been asked but has not arrived.
*
* A team share also has no `holder_user_id` -- its holder is the group
* -- so both columns are checked, or every team share is listed here
* as an invite to a blank address.
* A team share also has no `holder_user_id` -- its holder is the group --
* so both columns are checked, or every team share is listed here as an
* invite to a blank address.
*
* @param {number} fsentryId
*/
+55 -6
View File
@@ -36,6 +36,8 @@ export interface TeamRow {
handle: string | null;
deleted_at: string | null;
created_at: string;
/** Whether an app acting for a member may read the member list. */
directory_enabled: number;
}
export const TEAM_KIND = 'team';
@@ -82,8 +84,8 @@ export const AUDIT_PAGE_CAP = 200;
/**
* Ceiling on a share announcement's fan-out. Well above the default seat cap,
* so it bounds a pathological team rather than a real one; the caller logs
* when it bites, because a silently shortened fan-out reads as "everyone knows".
* so it bounds a pathological team rather than a real one; the caller logs when
* it bites, because a silently shortened fan-out reads as "everyone knows".
*/
export const NOTIFY_FANOUT_CAP = 500;
@@ -259,7 +261,11 @@ export class TeamStore extends PuterStore {
/** Null when no live team has that uid. `handle: null` releases it. */
async update(
uid: string,
changes: { name?: string; handle?: string | null },
changes: {
name?: string;
handle?: string | null;
directoryEnabled?: boolean;
},
): Promise<TeamRow | null> {
const sets: string[] = [];
const params: unknown[] = [];
@@ -278,6 +284,10 @@ export class TeamStore extends PuterStore {
sets.push('`handle` = ?');
params.push(changes.handle);
}
if (changes.directoryEnabled !== undefined) {
sets.push('`directory_enabled` = ?');
params.push(changes.directoryEnabled ? 1 : 0);
}
if (sets.length === 0) return this.getByUid(uid);
await this.clients.db.write(
@@ -373,6 +383,47 @@ export class TeamStore extends PuterStore {
return { items, cursor };
}
/**
* The directory page: who a member may be suggested alongside. Excludes
* suspended accounts and ones that never activated -- offering someone who
* cannot sign in is noise, and their existence is not this list's to tell.
*
* Activation is `requires_password_change` clearing, not the password
* existing: a provisioned seat holds the temporary one from birth.
*/
async listDirectory(
teamUid: string,
opts: { limit?: unknown; cursor?: string } = {},
): Promise<PageResult<{ id: number; username: string; uuid: string }>> {
const limit =
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
MEMBER_PAGE_SIZE;
const page = decodeCursor(opts.cursor, 'team directory cursor');
const after = typeof page?.id === 'number' ? page.id : null;
const rows = (await this.clients.db.read(
'SELECT ug.`id`, u.`username`, u.`uuid` FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
`WHERE g.\`uid\` = ? AND g.${this.#live()} ` +
'AND (u.`suspended` IS NULL OR u.`suspended` = 0) ' +
'AND (u.`requires_password_change` IS NULL ' +
'OR u.`requires_password_change` = 0)' +
(after === null ? '' : ' AND ug.`id` > ?') +
' ORDER BY ug.`id` LIMIT ?',
after === null
? [teamUid, TEAM_KIND, limit + 1]
: [teamUid, TEAM_KIND, after, limit + 1],
)) as unknown as { id: number; username: string; uuid: string }[];
const items = rows.slice(0, limit);
const cursor =
rows.length > limit
? encodeCursor({ id: items[items.length - 1].id })
: undefined;
return { items, cursor };
}
/** Internal ids of this user's live teams, for the share listing. */
async listGroupIdsForUser(userId: number): Promise<number[]> {
const rows = (await this.clients.db.read(
@@ -546,8 +597,6 @@ export class TeamStore extends PuterStore {
return Number(rows[0]?.n ?? 0);
}
/** Soft-deleted teams count: their seats still hold billable bytes. */
async getOrgSeat(userId: number): Promise<OrgSeatRow | null> {
return this.#cached(`team:seat:${userId}`, () =>
@@ -630,7 +679,7 @@ export class TeamStore extends PuterStore {
// 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",
postgres: 'EXTRACT(EPOCH FROM `created_at`)::bigint',
mysql: 'UNIX_TIMESTAMP(`created_at`)',
otherwise: "CAST(strftime('%s', `created_at`) AS INTEGER)",
});
+4 -4
View File
@@ -67,10 +67,10 @@ export interface UserRow {
/** True while the account must complete credit-card verification before use. */
requires_card_verification?: boolean;
/**
* 1 while the account still holds a password its team administrator
* issued; enforced by `assertVerifiedAccount` and cleared only by the
* account choosing its own. Unlike the other `requires_*` flags this is a
* numeric column on every dialect, so it is not normalized to a boolean.
* 1 while the account still holds a password its team administrator issued;
* enforced by `assertVerifiedAccount` and cleared only by the account
* choosing its own. Unlike the other `requires_*` flags this is a numeric
* column on every dialect, so it is not normalized to a boolean.
*/
requires_password_change?: number;
/**
+4 -4
View File
@@ -683,10 +683,10 @@ interface IConfigOptional {
*/
pub_port: number;
/**
* Teams and teams. Off means `/teams` 404s and the schema is inert, so
* the tables can ship to production before anything can create a team.
* It is also the backout: turning it off removes the feature without
* touching data.
* Teams and teams. Off means `/teams` 404s and the schema is inert, so the
* tables can ship to production before anything can create a team. It is
* also the backout: turning it off removes the feature without touching
* data.
*/
teams_enabled: boolean;
/** Live teams one user may own. Default 1. */
+41
View File
@@ -173,6 +173,20 @@ const renderMemberView = () => {
return h + renderAudit();
};
const renderDirectory = () => {
const on = state.selected?.directoryEnabled === true;
let h = '<div class="dashboard-card teams-panel">';
h += `<h2>${i18n('teams_directory')}</h2>`;
h += `<p class="teams-panel-hint">${i18n('teams_directory_hint')}</p>`;
h += '<label class="teams-directory-toggle">';
h += `<input type="checkbox" class="teams-directory-check"${on ? ' checked' : ''}>`;
h += `<span>${i18n('teams_directory_label')}</span>`;
h += '</label>';
h += `<p class="teams-directory-note">${i18n(on ? 'teams_directory_on_note' : 'teams_directory_off_note')}</p>`;
h += '</div>';
return h;
};
const renderOwnerView = () => {
let h = '<div class="dashboard-card teams-panel teams-card">';
h += '<div class="teams-info">';
@@ -182,6 +196,7 @@ const renderOwnerView = () => {
h += `<button class="button teams-rename">${i18n('teams_rename')}</button>`;
h += '</div>';
h += renderDirectory();
h += renderAddAccount();
h += renderMembers();
h += renderAudit();
@@ -377,6 +392,29 @@ const deleteMemberAccount = async ($el_window, username) => {
}
};
const setDirectoryEnabled = async ($el_window, enabled) => {
// Turning it on is a disclosure, so it is confirmed; turning it off only
// takes something away and does not need to interrupt anyone.
if ( enabled ) {
const ok = await confirm(
$el_window,
i18n('teams_directory_confirm'),
i18n('teams_directory_confirm_action'),
// Reversible, and the wording says so — red would overstate it.
'primary',
);
// Repaint so the checkbox does not sit checked after a refusal.
if ( ! ok ) return paint($el_window);
}
try {
await puter.teams.update(state.selected.uid, { directoryEnabled: enabled });
await refresh($el_window);
} catch (e) {
await showError($el_window, e);
await refresh($el_window);
}
};
const createTeam = async ($el_window) => {
const name = await UIPrompt({
message: i18n('teams_create_team_prompt'),
@@ -450,6 +488,9 @@ const TabTeams = {
$el_window.on('click', `${SECTION} .teams-delete-account`, function () {
deleteMemberAccount($el_window, $(this).attr('data-username'));
});
$el_window.on('change', `${SECTION} .teams-directory-check`, function () {
setDirectoryEnabled($el_window, $(this).is(':checked'));
});
$el_window.on('change', `${SECTION} .teams-picker-select`, async function () {
state.selected = state.teams.find(t => t.uid === $(this).val()) ?? state.selected;
await refresh($el_window);
+2
View File
@@ -39,6 +39,8 @@ const AUDIT_ACTION_KEYS = {
reset_member_password: 'teams_audit_reset_member_password',
activate: 'teams_audit_activate',
delete_account: 'teams_audit_delete_account',
directory_enabled: 'teams_audit_directory_enabled',
directory_disabled: 'teams_audit_directory_disabled',
};
/** i18n keys for the reasons the team attaches to an action. */
@@ -134,6 +134,7 @@ describe('audit labels', () => {
it.each([
'provision', 'disable', 'enable', 'delete_team',
'reset_member_password', 'activate', 'delete_account',
'directory_enabled', 'directory_disabled',
])('has a label for %s', (action) => {
expect(auditActionKey(action)).not.toBeNull();
});
+14
View File
@@ -8071,3 +8071,17 @@ body.dashboard-mode .notifications-close-all {
flex-basis: 100%;
}
}
.teams-directory-toggle {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0 10px;
cursor: pointer;
}
.teams-directory-note {
margin: 0;
font-size: 13px;
opacity: 0.7;
}
+9
View File
@@ -564,6 +564,15 @@ const en = {
teams_audit_reset_member_password: 'Password reset',
teams_audit_activate: 'Chose their own password',
teams_audit_delete_account: 'Account deleted',
teams_audit_directory_enabled: 'Directory opened to apps',
teams_audit_directory_disabled: 'Directory closed to apps',
teams_directory: 'Directory',
teams_directory_hint: 'Whether the apps your members use may look up who else is here.',
teams_directory_label: 'Let apps suggest colleagues by name',
teams_directory_on_note: 'Apps your members use can read usernames of everyone active here. They cannot see emails, records, or suspended accounts.',
teams_directory_off_note: 'Members can still see each other. Only apps are shut out.',
teams_directory_confirm: 'Apps your members install will be able to read the usernames of everyone active in this team. They will not see emails, records, or suspended accounts. You can turn this off again at any time.',
teams_directory_confirm_action: 'Open the directory',
teams_audit_reason_team_deleted: '(team deleted)',
teams_your_record: 'Your record',
teams_your_record_hint:
+3 -1
View File
@@ -8,6 +8,7 @@ import { enableMember } from './enableMember.js';
import { get } from './get.js';
import { list } from './list.js';
import { listAudit } from './listAudit.js';
import { listDirectory } from './listDirectory.js';
import { listMembers } from './listMembers.js';
import { listOwnAudit } from './listOwnAudit.js';
import { resendActivation } from './resendActivation.js';
@@ -23,7 +24,7 @@ const METHODS = [
'create', 'list', 'get', 'update', 'delete',
'listMembers', 'createMember', 'resendActivation',
'disableMember', 'enableMember', 'resetPassword', 'deleteMemberAccount',
'listAudit', 'listOwnAudit',
'listAudit', 'listOwnAudit', 'listDirectory',
];
/**
@@ -58,6 +59,7 @@ export class TeamsModule extends PuterModule {
listAudit = listAudit;
listOwnAudit = listOwnAudit;
listDirectory = listDirectory;
/** @param {Puter} puter */
constructor (puter) {
@@ -16,10 +16,22 @@ export function toTeam (row) {
name: /** @type {string | null} */ (row.name ?? null),
handle: /** @type {string | null} */ (row.handle ?? null),
isOwner: row.is_owner === true,
directoryEnabled: row.directory_enabled === true,
createdAt: /** @type {string} */ (row.created_at),
};
}
/**
* @param {Record<string, unknown>} row
* @returns {import('../types.js').TeamDirectoryEntry}
*/
export function toDirectoryEntry (row) {
return {
username: /** @type {string} */ (row.username),
uuid: /** @type {string} */ (row.uuid),
};
}
/**
* @param {Record<string, unknown>} row
* @returns {TeamMember}
@@ -0,0 +1,49 @@
import { listRoute } from './lib/listRoute.js';
import { requireSegment } from './lib/req.js';
import { mapListResult, toDirectoryEntry } from './lib/shapes.js';
/** @typedef {import('./types.js').TeamDirectoryEntry} TeamDirectoryEntry */
/** @typedef {import('../../lib/types.js').ListPage<TeamDirectoryEntry>} TeamDirectoryPage */
/** @typedef {Omit<import('../../lib/types.js').ListPaginationOptions, 'offset'>} TeamListOptions */
/**
* @overload
* @param {string} uid
* @param {import('../../lib/types.js').ListStreamOptions} options
* @returns {AsyncIterableIterator<TeamDirectoryPage>}
*/
/**
* @overload
* @param {string} uid
* @param {TeamListOptions & ({ cursor: string | null } | { includeTotal: true })} options
* @returns {Promise<TeamDirectoryPage>}
*/
/**
* @overload
* @param {string} uid
* @param {{ limit?: number }} [options]
* @returns {Promise<TeamDirectoryEntry[]>}
*/
/**
* The colleagues a member may be offered alongside for suggesting invitees
* and the like. Unlike every other `puter.teams` method this is callable by an
* app acting for the member, not only by the member directly.
*
* It rejects with `team_not_found` unless the team has turned its
* directory on, which it has not by default. Suspended accounts and ones that
* never took up their credential are left out.
*
* The membership tested is always the *person's*, so an app installed by a
* member of one team can never read another's.
*
* @this {import('./index.js').TeamsModule}
* @param {string} uid
* @param {TeamListOptions | import('../../lib/types.js').ListStreamOptions} [options]
* @returns {Promise<TeamDirectoryEntry[]> | Promise<TeamDirectoryPage> | AsyncIterableIterator<TeamDirectoryPage>}
*/
export function listDirectory (uid, options) {
const segment = requireSegment(uid, 'uid');
return /** @type {Promise<TeamDirectoryEntry[]>} */ (
mapListResult(listRoute(this.puter, `/teams/${segment}/directory`, options, 'listDirectory'), toDirectoryEntry)
);
}
@@ -23,6 +23,7 @@ const TEAM = {
name: 'Acme',
handle: 'acme',
isOwner: true,
directoryEnabled: false,
createdAt: '2026-01-01T00:00:00Z',
};
@@ -248,6 +249,36 @@ describe('members', () => {
});
});
describe('directory', () => {
it('lists colleagues, mapping the row to the public shape', async () => {
routes({
'GET /teams/t-1/directory': {
items: [{ username: 'ana', uuid: 'u-1' }, { username: 'bo', uuid: 'u-2' }],
},
});
await expect(teams.listDirectory('t-1')).resolves.toEqual([
{ username: 'ana', uuid: 'u-1' },
{ username: 'bo', uuid: 'u-2' },
]);
});
it('surfaces the closed directory as the same not-found the API gives', async () => {
routes({
'GET /teams/t-1/directory': () => {
throw Object.assign(new Error('Team not found'), { code: 'team_not_found' });
},
});
await expect(teams.listDirectory('t-1')).rejects.toMatchObject({ code: 'team_not_found' });
});
it('sends the toggle as the snake_case the route expects', async () => {
routes({ 'PUT /teams/t-1': { ...TEAM_ROW, directory_enabled: true } });
const res = await teams.update('t-1', { directoryEnabled: true });
expect(call().body).toEqual({ directory_enabled: true });
expect(res.directoryEnabled).toBe(true);
});
});
describe('binding', () => {
it('keeps `this` when a method is destructured off the module', async () => {
routes({ 'GET /teams': { items: [TEAM_ROW] }, 'POST /teams': TEAM_ROW });
+10
View File
@@ -10,6 +10,8 @@
* @property {string | null} name The team's display name.
* @property {string | null} handle The team's short handle, unique while it exists. `null` when unset.
* @property {boolean} isOwner Whether the caller is the owner account of this team.
* @property {boolean} directoryEnabled Whether apps acting for a member may read the member list
* through `Teams.listDirectory()`. Off unless the owner account turns it on.
* @property {string} createdAt When the team was created, in `YYYY-MM-DDTHH:MM:SSZ` format.
*/
@@ -28,11 +30,19 @@
* @typedef {Object} UpdateTeamAttributes
* @property {string} [name] The team's new display name.
* @property {string | null} [handle] A new handle, or `null` to release the current one.
* @property {boolean} [directoryEnabled] Whether apps acting for a member may read the member list.
* Off by default; turning it on is recorded in the team's audit log.
*/
/**
* An account belonging to a team.
*
* @typedef {Object} TeamDirectoryEntry
* @property {string} username The colleague's Puter username.
* @property {string} uuid Their account identifier, stable across a username change.
*/
/**
* @typedef {Object} TeamMember
* @property {string} username The member's Puter username.
* @property {boolean} orgOwned Whether the team provisioned and pays for this account, as opposed
+1
View File
@@ -17,6 +17,7 @@ export async function update (uid, attributes) {
const body = {};
if ( attributes?.name !== undefined ) body.name = attributes.name;
if ( attributes?.handle !== undefined ) body.handle = attributes.handle;
if ( attributes?.directoryEnabled !== undefined ) body.directory_enabled = attributes.directoryEnabled;
return toTeam(await req(this.puter, 'PUT', `/teams/${segment}`, { body, operation: 'update' }));
}