diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts
index 1a85074d6..707158667 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.test.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts
@@ -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
diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts
index f3e173628..3ab13dc0d 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.ts
@@ -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 {
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_38.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_38.sql
new file mode 100644
index 000000000..32c06c645
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_38.sql
@@ -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 .
+
+-- 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');
diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_27.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_27.sql
new file mode 100644
index 000000000..f35a8095a
--- /dev/null
+++ b/src/backend/clients/database/migrations/postgres/postgres_mig_27.sql
@@ -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 .
+
+-- 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;
diff --git a/src/backend/clients/database/migrations/sqlite/0083_team-directory.sql b/src/backend/clients/database/migrations/sqlite/0083_team-directory.sql
new file mode 100644
index 000000000..87f69ba7b
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0083_team-directory.sql
@@ -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 .
+
+-- 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;
diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts
index d5c759e76..aed48c3e0 100644
--- a/src/backend/clients/event/types.ts
+++ b/src/backend/clients/event/types.ts
@@ -36,9 +36,9 @@ type GuiEvent> = {
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 = {
diff --git a/src/backend/controllers/team/TeamController.http.test.ts b/src/backend/controllers/team/TeamController.http.test.ts
index 0c1f4e84d..71891ecd0 100644
--- a/src/backend/controllers/team/TeamController.http.test.ts
+++ b/src/backend/controllers/team/TeamController.http.test.ts
@@ -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();
diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts
index f3269578a..07cefecb5 100644
--- a/src/backend/controllers/team/TeamController.ts
+++ b/src/backend/controllers/team/TeamController.ts
@@ -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 {
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 {
+ // 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,
diff --git a/src/backend/services/share/ShareNotificationService.ts b/src/backend/services/share/ShareNotificationService.ts
index 23144de09..c78cee54b 100644
--- a/src/backend/services/share/ShareNotificationService.ts
+++ b/src/backend/services/share/ShareNotificationService.ts
@@ -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.
diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts
index 80eb5c757..aaaa9078a 100644
--- a/src/backend/services/share/ShareService.ts
+++ b/src/backend/services/share/ShareService.ts
@@ -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 | null): boolean =>
- Boolean(user?.metadata?.[BLOCK_ALL_SHARES_KEY]);
+export const blocksAllShares = (
+ user: Pick | 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 {
diff --git a/src/backend/services/team/TeamEvents.test.ts b/src/backend/services/team/TeamEvents.test.ts
index 090d31243..c8629e1bd 100644
--- a/src/backend/services/team/TeamEvents.test.ts
+++ b/src/backend/services/team/TeamEvents.test.ts
@@ -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;
diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts
index 3845d8fc0..7ab11c0ba 100644
--- a/src/backend/services/team/TeamService.ts
+++ b/src/backend/services/team/TeamService.ts
@@ -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 {
- 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> {
+ 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 {
// 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,
diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js
index 3e187a272..da40456ec 100644
--- a/src/backend/stores/share/ShareStore.js
+++ b/src/backend/stores/share/ShareStore.js
@@ -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
*/
diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts
index a7c04846a..32bd7d208 100644
--- a/src/backend/stores/team/TeamStore.ts
+++ b/src/backend/stores/team/TeamStore.ts
@@ -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 {
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> {
+ 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 {
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 {
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)",
});
diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts
index 21ba8a6ef..d28ad4c9c 100644
--- a/src/backend/stores/user/UserStore.ts
+++ b/src/backend/stores/user/UserStore.ts
@@ -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;
/**
diff --git a/src/backend/types.ts b/src/backend/types.ts
index edd88502d..850b1afbe 100644
--- a/src/backend/types.ts
+++ b/src/backend/types.ts
@@ -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. */
diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js
index 1bd6ca81a..577f4da30 100644
--- a/src/gui/src/UI/Dashboard/TabTeams.js
+++ b/src/gui/src/UI/Dashboard/TabTeams.js
@@ -173,6 +173,20 @@ const renderMemberView = () => {
return h + renderAudit();
};
+const renderDirectory = () => {
+ const on = state.selected?.directoryEnabled === true;
+ let h = '