mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 12:46:00 +00:00
feat: give a team seat the account surface that is actually its own
A provisioned account could rename itself, delete itself, and see a
Billing tab for a subscription it does not hold — all of it the team's,
not the account's. Each is now refused server-side and dropped from the
UI, keyed on one predicate: whoami reports a team only for an org-owned
seat, and the owner joined their own team.
The Teams tab showed a seat nothing but its own audit rows, so a member
could not see who else was on the team they were told they shared it
with. It now lists them; listMembers was already membership-gated and
already withholds from a member what is not theirs.
The dashboard share modal had no way to reach a team, though the desktop
dialog has had one since team sharing shipped and the SDK has always
taken `{ team }`. Same control, same copy, same helper. The access list
needed a team bucket to go with it: a team share names no holder, so the
aggregate dropped it and a team you had just shared with vanished.
This commit is contained in:
@@ -5253,6 +5253,32 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('change-username: 403 for an account its team provisioned', async () => {
|
||||
// The console lists members by username and the audit log records them
|
||||
// by it; a self-service rename would desync both.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
// addMember refuses to adopt an account that has one.
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
makeReq({ new_username: `r_${uniq()}` }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
expect(after!.username).toBe(seat.username);
|
||||
});
|
||||
|
||||
it('change-username: persists the rename and emits user.username-changed', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const newUsername = `r_${uniq()}`;
|
||||
@@ -6487,6 +6513,28 @@ describe('AuthController.handleDeleteOwnUser', () => {
|
||||
expect(after).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses, and keeps the row, for an account its team provisioned', async () => {
|
||||
// The team is billed for the seat and closing it is theirs to do, from
|
||||
// the console that keeps the audit trail.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
expect(after).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
|
||||
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
|
||||
// the sqlite ones the test harness runs — add it so the delete path
|
||||
|
||||
@@ -2551,6 +2551,17 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
async handleChangeUsername(req: Request, res: Response): Promise<void> {
|
||||
// A provisioned account's name belongs to the team that made it: the
|
||||
// console lists its members by username and the audit log records them
|
||||
// by username, so a self-service rename would desync both.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this username. Ask a team admin to change it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
const { new_username } = req.body ?? {};
|
||||
if (!new_username || typeof new_username !== 'string') {
|
||||
throw new HttpError(400, '`new_username` is required', {
|
||||
@@ -4374,6 +4385,15 @@ export class AuthController extends PuterController {
|
||||
|
||||
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
// The team owns the account and is billed for it; only they may close
|
||||
// it, through the console that keeps the audit trail.
|
||||
if (await this.stores.team.getOrgSeat(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team owns this account. Ask a team admin to remove it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
res.clearCookie('puter_revalidation');
|
||||
|
||||
@@ -24,6 +24,7 @@ import UIWindowConfirmUserDeletion from '../Settings/UIWindowConfirmUserDeletion
|
||||
import UIWindowCopyToken from '../UIWindowCopyToken.js';
|
||||
import UIWindow from '../UIWindow.js';
|
||||
import UIProfilePictureCropModal from './UIProfilePictureCropModal.js';
|
||||
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
|
||||
const TabAccount = {
|
||||
id: 'account',
|
||||
@@ -67,7 +68,17 @@ const TabAccount = {
|
||||
h += `<span class="username">${html_encode(window.user.username)}</span>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
h += `<button class="button change-username">${i18n('change_username')}</button>`;
|
||||
// A seat's name is the team's; the console and audit log key on it.
|
||||
if ( isOrgSeat(window.user) ) {
|
||||
const team = orgSeatTeamName(window.user);
|
||||
h += `<span class="dashboard-settings-card-note">${
|
||||
team
|
||||
? i18n('username_set_by_team', { team })
|
||||
: i18n('username_set_by_team_generic')
|
||||
}</span>`;
|
||||
} else {
|
||||
h += `<button class="button change-username">${i18n('change_username')}</button>`;
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
// Password card (only for non-temp users)
|
||||
@@ -124,18 +135,20 @@ const TabAccount = {
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
// Danger zone
|
||||
h += '<div class="dashboard-danger-zone">';
|
||||
h += '<div class="dashboard-card dashboard-danger-card">';
|
||||
h += '<div class="dashboard-danger-card-content">';
|
||||
h += '<div class="dashboard-danger-card-info">';
|
||||
h += `<strong>${i18n('delete_account')}</strong>`;
|
||||
h += '<span>Permanently delete your account and all associated data. This action cannot be undone.</span>';
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
h += `<button class="button button-danger delete-account">${i18n('delete_account')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
// Danger zone. A seat has none: the team owns the account.
|
||||
if ( ! isOrgSeat(window.user) ) {
|
||||
h += '<div class="dashboard-danger-zone">';
|
||||
h += '<div class="dashboard-card dashboard-danger-card">';
|
||||
h += '<div class="dashboard-danger-card-content">';
|
||||
h += '<div class="dashboard-danger-card-info">';
|
||||
h += `<strong>${i18n('delete_account')}</strong>`;
|
||||
h += '<span>Permanently delete your account and all associated data. This action cannot be undone.</span>';
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
h += `<button class="button button-danger delete-account">${i18n('delete_account')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
h += '</div>'; // end settings-grid
|
||||
|
||||
|
||||
@@ -206,13 +206,34 @@ const renderAudit = () => {
|
||||
return h;
|
||||
};
|
||||
|
||||
/** What a member sees: their own entries, and nothing administrative. */
|
||||
/** Colleagues, by name. No state, no dates, no actions — none are theirs. */
|
||||
const renderRoster = () => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_roster')}</h3>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_roster_hint')}</p>`;
|
||||
if ( state.members.length === 0 ) {
|
||||
h += `<p class="teams-empty">${i18n('teams_roster_empty')}</p>`;
|
||||
} else {
|
||||
h += '<ul class="teams-roster">';
|
||||
for ( const member of sortMembers(state.members) ) {
|
||||
const you = member.username === window.user?.username;
|
||||
h += `<li class="teams-roster-name">${html_encode(member.username)}`;
|
||||
if ( you ) h += ` <span class="teams-roster-you">(${i18n('share_you')})</span>`;
|
||||
h += '</li>';
|
||||
}
|
||||
h += '</ul>';
|
||||
}
|
||||
h += '</div>';
|
||||
return h;
|
||||
};
|
||||
|
||||
/** What a member sees: who else is here, their own entries, nothing admin. */
|
||||
const renderMemberView = () => {
|
||||
let h = '<div class="dashboard-card teams-panel">';
|
||||
h += `<h3>${i18n('teams_your_record')}</h3>`;
|
||||
h += `<p class="teams-panel-hint">${i18n('teams_your_record_hint', { team: teamName(state.selected) })}</p>`;
|
||||
h += '</div>';
|
||||
return h + renderAudit();
|
||||
return h + renderRoster() + renderAudit();
|
||||
};
|
||||
|
||||
const renderDirectory = () => {
|
||||
@@ -283,9 +304,9 @@ const loadSelected = async () => {
|
||||
state.audit = state.selected.isOwner
|
||||
? await puter.teams.listAudit(state.selected.uid)
|
||||
: await puter.teams.listOwnAudit(state.selected.uid);
|
||||
state.members = state.selected.isOwner
|
||||
? await puter.teams.listMembers(state.selected.uid)
|
||||
: [];
|
||||
// Members too: the roster is theirs to see, and the controller already
|
||||
// withholds from them what is not.
|
||||
state.members = await puter.teams.listMembers(state.selected.uid);
|
||||
state.plan = state.selected.isOwner ? await loadPlan(state.selected.uid) : null;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../../helpers/sharedBadge.js';
|
||||
import { share_outcome } from '../../helpers/shareOutcome.js';
|
||||
import { aggregateOwners, aggregateShares, missingPathsFor } from './shareAggregate.js';
|
||||
import { team_label, teams_for_sharing } from '../../helpers/shareTeams.js';
|
||||
|
||||
const { html_encode } = window;
|
||||
|
||||
@@ -130,6 +131,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
// Nothing to share: an empty selection is a caller's mistake, not a dialog.
|
||||
if ( total === 0 ) return { close: () => {} };
|
||||
const items_list_id = `share-modal-items-${++modal_seq}`;
|
||||
const teams_select_id = `share-modal-teams-${modal_seq}`;
|
||||
|
||||
// The header names the one item, or the size of the pile with the names
|
||||
// folded into an expandable list below it.
|
||||
@@ -177,6 +179,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
<span class="share-modal-submit-label">${i18n('share')}</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="share-modal-teams" hidden></div>
|
||||
<div class="share-modal-status" role="status" aria-live="polite"></div>
|
||||
<h3 class="share-modal-heading">${i18n('share_who_has_access')}</h3>
|
||||
<div class="share-modal-list" aria-busy="true">
|
||||
@@ -512,6 +515,53 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
}
|
||||
};
|
||||
|
||||
/** A team is named by uid; a person by the name the row already shows. */
|
||||
const recipient_of = (group) => (group.teamUid ? { team: group.teamUid } : group.name);
|
||||
|
||||
// One grant reaching every colleague. Its own control, as on the desktop
|
||||
// dialog: a bare string in the field above already reads as a person.
|
||||
let teams = [];
|
||||
(async () => {
|
||||
teams = await teams_for_sharing();
|
||||
if ( closed || teams.length === 0 ) return;
|
||||
const options = teams
|
||||
.map((team) => `<option value="${html_encode(team.uid)}">${html_encode(team_label(team))}</option>`)
|
||||
.join('');
|
||||
$overlay.find('.share-modal-teams').html(`
|
||||
<label class="share-modal-teams-label" for="${teams_select_id}">${i18n('share_with_team')}</label>
|
||||
<div class="share-modal-add-row">
|
||||
<select class="share-modal-team-select" id="${teams_select_id}">${options}</select>
|
||||
<select class="share-modal-team-mode" aria-label="${i18n('share_access_level')}">${options_for('read', { allow_manage })}</select>
|
||||
</div>
|
||||
<p class="share-modal-teams-note">${i18n('share_team_note')}</p>
|
||||
<button type="button" class="share-modal-team-btn">${i18n('share')}</button>
|
||||
`).prop('hidden', false);
|
||||
})();
|
||||
|
||||
$overlay.on('click', '.share-modal-team-btn', async function () {
|
||||
const team = teams.find((t) => t.uid === $overlay.find('.share-modal-team-select').val());
|
||||
if ( ! team ) return;
|
||||
const name = team_label(team);
|
||||
const $btn = $(this).prop('disabled', true);
|
||||
try {
|
||||
const created = await grant_access(
|
||||
{ team: team.uid },
|
||||
$overlay.find('.share-modal-team-mode').val(),
|
||||
target_paths,
|
||||
);
|
||||
const granted = created?.length ?? 0;
|
||||
show_success(granted < total
|
||||
? i18n('share_shared_with_partial', { recipient: name, count: granted, total })
|
||||
: shared_message(name, total));
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
show_error(error_html(err));
|
||||
}
|
||||
$btn.prop('disabled', false);
|
||||
focus_dialog();
|
||||
});
|
||||
|
||||
/** "Shared with ann" / "Shared with ann on 4 items". */
|
||||
const shared_message = (recipient, count) => (count === 1
|
||||
? i18n('share_shared_with', { recipient })
|
||||
@@ -583,7 +633,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
if ( ! group || ! mode ) return;
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
await grant_access(group.name, mode, group.directPaths);
|
||||
await grant_access(recipient_of(group), mode, group.directPaths);
|
||||
show_success(group.directPaths.length > 1
|
||||
? i18n('share_access_updated_items', { recipient: group.name, count: group.directPaths.length })
|
||||
: i18n('share_access_updated', { recipient: group.name }));
|
||||
@@ -604,7 +654,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
if ( ! missing.length ) return;
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
const created = await grant_access(group.name, group.mode, missing);
|
||||
const created = await grant_access(recipient_of(group), group.mode, missing);
|
||||
const granted = created?.length ?? 0;
|
||||
show_success(granted < missing.length
|
||||
? i18n('share_shared_with_partial', { recipient: group.name, count: granted, total: missing.length })
|
||||
@@ -677,7 +727,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
const revoke_paths = group.pending ? group.pendingPaths : group.directPaths;
|
||||
$(this).closest('.share-modal-row-confirm').find('button').prop('disabled', true);
|
||||
try {
|
||||
await revoke_access(group.name, revoke_paths);
|
||||
await revoke_access(recipient_of(group), revoke_paths);
|
||||
if ( group.pending ) {
|
||||
show_success(i18n('share_invite_cancelled', { recipient: group.name }));
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* An account its team created and pays for.
|
||||
*
|
||||
* whoami sends `team` only for a seat — the owner joined their own team and is
|
||||
* never `org_owned` — so its presence is the whole test. One predicate because
|
||||
* the surfaces that restrict a seat (billing, plan purchase, username) must
|
||||
* agree; the backend refuses each of them regardless.
|
||||
*
|
||||
* @param {object} [user] - `window.user`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOrgSeat = (user) =>
|
||||
typeof user?.team?.uid === 'string' && user.team.uid !== '';
|
||||
|
||||
/** The team's name, for telling the user who to ask. */
|
||||
export const orgSeatTeamName = (user) =>
|
||||
(typeof user?.team?.name === 'string' && user.team.name.trim()) || null;
|
||||
|
||||
export default isOrgSeat;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
|
||||
describe('recognising a team-owned account', () => {
|
||||
it('is a seat when whoami sent a team', () => {
|
||||
expect(isOrgSeat({ team: { uid: 't-1', name: 'Acme' } })).toBe(true);
|
||||
});
|
||||
|
||||
it('is not a seat for an ordinary account, or before whoami lands', () => {
|
||||
// Every restriction keys on this, so a missing user must not read as
|
||||
// "seat" and lock an ordinary account out of its own settings.
|
||||
expect(isOrgSeat({})).toBe(false);
|
||||
expect(isOrgSeat(undefined)).toBe(false);
|
||||
expect(isOrgSeat(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('needs the uid, not just the key', () => {
|
||||
expect(isOrgSeat({ team: {} })).toBe(false);
|
||||
expect(isOrgSeat({ team: { uid: '' } })).toBe(false);
|
||||
expect(isOrgSeat({ team: { name: 'Acme' } })).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the team name, and treats a blank one as none', () => {
|
||||
expect(orgSeatTeamName({ team: { uid: 't', name: 'Acme' } })).toBe('Acme');
|
||||
expect(orgSeatTeamName({ team: { uid: 't', name: ' ' } })).toBe(null);
|
||||
expect(orgSeatTeamName({ team: { uid: 't' } })).toBe(null);
|
||||
expect(orgSeatTeamName(undefined)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { team_label } from '../../helpers/shareTeams.js';
|
||||
|
||||
// Folds the per-item share listings behind the share dialog into one row per
|
||||
// person, so a selection of many items reads as a single access list. Pure, so
|
||||
// the rules that decide what a row may change are testable without a DOM.
|
||||
@@ -32,8 +34,9 @@
|
||||
* have entries.
|
||||
*
|
||||
* @typedef {Object} ShareGroup
|
||||
* @property {string} key - Row identity: `user:<username>` or `invite:<email>`
|
||||
* @property {string} name - Username, or the invited email address
|
||||
* @property {string} key - Row identity: `user:<username>`, `invite:<email>` or `team:<uid>`
|
||||
* @property {string} name - Username, invited email address, or team name
|
||||
* @property {string|null} teamUid - Set when the holder is a team, not a person
|
||||
* @property {boolean} pending - Invitation with no account behind it yet
|
||||
* @property {string[]} directPaths - Items whose grant this dialog can change
|
||||
* @property {string[]} pendingPaths - Items the invitation covers
|
||||
@@ -71,6 +74,30 @@ const bucket_of = (share) => {
|
||||
return 'direct';
|
||||
};
|
||||
|
||||
/**
|
||||
* Row identity and label. A team share names no holder, so it is keyed on the
|
||||
* team's uid — a renamed team stays the same row.
|
||||
*
|
||||
* @param {Object} share
|
||||
* @param {'pending'|'inherited'|'direct'} bucket
|
||||
* @returns {{ key: string, name: string, teamUid: string|null }|null}
|
||||
*/
|
||||
const identify = (share, bucket) => {
|
||||
const team = share.holderTeam;
|
||||
if ( team?.uid ) {
|
||||
return { key: `team:${team.uid}`, name: team_label(team), teamUid: team.uid };
|
||||
}
|
||||
const name = bucket === 'pending'
|
||||
? (share.recipientEmail ?? '')
|
||||
: (share.holder ?? '');
|
||||
if ( name === '' ) return null;
|
||||
return {
|
||||
key: `${bucket === 'pending' ? 'invite' : 'user'}:${name}`,
|
||||
name,
|
||||
teamUid: null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapses per-item share listings into one {@link ShareGroup} per person.
|
||||
*
|
||||
@@ -93,12 +120,10 @@ export const aggregateShares = (paths, sharesByPath) => {
|
||||
|
||||
for ( const share of sharesByPath.get(item_path) ?? [] ) {
|
||||
const bucket = bucket_of(share);
|
||||
const name = bucket === 'pending'
|
||||
? (share.recipientEmail ?? '')
|
||||
: (share.holder ?? '');
|
||||
if ( name === '' ) continue;
|
||||
const identity = identify(share, bucket);
|
||||
if ( ! identity ) continue;
|
||||
const { key, name, teamUid } = identity;
|
||||
|
||||
const key = `${bucket === 'pending' ? 'invite' : 'user'}:${name}`;
|
||||
if ( counted.has(`${key}|${bucket}`) ) continue;
|
||||
counted.add(`${key}|${bucket}`);
|
||||
|
||||
@@ -106,6 +131,7 @@ export const aggregateShares = (paths, sharesByPath) => {
|
||||
groups.set(key, {
|
||||
key,
|
||||
name,
|
||||
teamUid,
|
||||
pending: bucket === 'pending',
|
||||
directPaths: [],
|
||||
pendingPaths: [],
|
||||
@@ -141,6 +167,7 @@ export const aggregateShares = (paths, sharesByPath) => {
|
||||
return {
|
||||
key: group.key,
|
||||
name: group.name,
|
||||
teamUid: group.teamUid,
|
||||
pending: group.pending,
|
||||
directPaths: group.directPaths,
|
||||
pendingPaths: group.pendingPaths,
|
||||
|
||||
@@ -119,6 +119,54 @@ describe('aggregateShares', () => {
|
||||
|
||||
expect(groups.map((g) => g.name)).toEqual(['ann']);
|
||||
});
|
||||
|
||||
it('gives a team a row of its own, named and keyed on the team', () => {
|
||||
// A team share names no holder; without this it would be dropped as a
|
||||
// grant that names nobody and the share would vanish from the list.
|
||||
const groups = aggregateShares(['/me/a'], new Map([
|
||||
['/me/a', [grant(null, 'read', {
|
||||
holderTeam: { uid: 't-1', name: 'Acme', handle: 'acme' },
|
||||
})]],
|
||||
]));
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toMatchObject({
|
||||
key: 'team:t-1',
|
||||
name: 'Acme',
|
||||
teamUid: 't-1',
|
||||
mode: 'read',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the handle, then the uid, for an unnamed team', () => {
|
||||
// Whatever team_label says, so a team reads the same here as it does
|
||||
// in the desktop dialog's access list.
|
||||
const named = (team) => aggregateShares(['/me/a'], new Map([
|
||||
['/me/a', [grant(null, 'read', { holderTeam: team })]],
|
||||
]))[0].name;
|
||||
|
||||
expect(named({ uid: 't-1', name: null, handle: 'acme' })).toBe('acme');
|
||||
expect(named({ uid: 't-1', name: null, handle: null })).toBe('t-1');
|
||||
});
|
||||
|
||||
it('keeps a team and a person of the same name apart', () => {
|
||||
const groups = aggregateShares(['/me/a'], new Map([
|
||||
['/me/a', [
|
||||
grant('ann', 'read'),
|
||||
grant(null, 'write', { holderTeam: { uid: 't-1', name: 'ann' } }),
|
||||
]],
|
||||
]));
|
||||
|
||||
expect(groups.map((g) => g.key)).toEqual(['user:ann', 'team:t-1']);
|
||||
});
|
||||
|
||||
it('leaves teamUid null on a person, so their row still shares by name', () => {
|
||||
const groups = aggregateShares(['/me/a'], new Map([
|
||||
['/me/a', [grant('ann', 'read')]],
|
||||
]));
|
||||
|
||||
expect(groups[0].teamUid).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missingPathsFor', () => {
|
||||
|
||||
@@ -2531,6 +2531,8 @@ input.share-modal-recipient:focus {
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
select.share-modal-mode,
|
||||
select.share-modal-team-select,
|
||||
select.share-modal-team-mode,
|
||||
select.share-modal-row-mode {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@@ -2552,16 +2554,21 @@ select.share-modal-row-mode {
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
|
||||
}
|
||||
select.share-modal-mode {
|
||||
select.share-modal-mode,
|
||||
select.share-modal-team-mode {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
select.share-modal-mode:focus,
|
||||
select.share-modal-team-select:focus,
|
||||
select.share-modal-team-mode:focus,
|
||||
select.share-modal-row-mode:focus {
|
||||
padding: 8px 26px 8px 10px;
|
||||
border: 1px solid var(--select-color);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
select.share-modal-mode:disabled,
|
||||
select.share-modal-team-select:disabled,
|
||||
select.share-modal-team-mode:disabled,
|
||||
select.share-modal-row-mode:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
@@ -2572,10 +2579,69 @@ select.share-modal-row-mode:disabled {
|
||||
.device-phone input.share-modal-recipient,
|
||||
.device-phone input.share-modal-recipient:focus,
|
||||
.device-phone select.share-modal-mode,
|
||||
.device-phone select.share-modal-team-select,
|
||||
.device-phone select.share-modal-team-mode,
|
||||
.device-phone select.share-modal-row-mode {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Its own block under the people field, separated the way the desktop
|
||||
dialog separates them. The button is quieter: an alternative, not the
|
||||
main path. */
|
||||
.share-modal-teams {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--dashboard-border);
|
||||
}
|
||||
|
||||
.share-modal-teams-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
|
||||
.share-modal-teams-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-text-hint);
|
||||
}
|
||||
|
||||
.share-modal-team-select {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.share-modal-team-btn {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
margin-top: 10px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: var(--dashboard-text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.share-modal-team-btn:hover:not(:disabled) {
|
||||
background: var(--dashboard-card-background);
|
||||
border-color: var(--dashboard-text-hint);
|
||||
}
|
||||
}
|
||||
|
||||
.share-modal-team-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.share-modal-submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6326,6 +6392,16 @@ body.myapps-reordering .myapps-tile {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Sits where the card's button would, when the account may not press one. */
|
||||
.dashboard-settings-card-note {
|
||||
flex-shrink: 0;
|
||||
max-width: 240px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--dashboard-text-hint);
|
||||
}
|
||||
|
||||
.dashboard-settings-card .button {
|
||||
flex-shrink: 0;
|
||||
color: var(--dashboard-text-primary);
|
||||
@@ -7955,6 +8031,31 @@ body.dashboard-mode .notifications-close-all {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.teams-roster {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.teams-roster-name {
|
||||
padding: 7px 0;
|
||||
font-size: 14px;
|
||||
color: var(--dashboard-text-primary);
|
||||
border-bottom: 1px solid var(--dashboard-border);
|
||||
}
|
||||
|
||||
.teams-roster-name:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.teams-roster-you {
|
||||
font-size: 13px;
|
||||
color: var(--dashboard-text-hint);
|
||||
}
|
||||
|
||||
.teams-member-actions .button-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -80,6 +80,8 @@ const en = {
|
||||
change_profile_picture: 'Change profile picture',
|
||||
change_ui_colors: 'Change UI Colors',
|
||||
change_username: 'Change Username',
|
||||
username_set_by_team: 'Set by {{team}}. Ask an admin to change it.',
|
||||
username_set_by_team_generic: 'Set by your team. Ask an admin to change it.',
|
||||
revalidate_with_google: 'Re-validate with Google',
|
||||
revalidated: 'Re-validated.',
|
||||
revalidate_sign_in_popup: 'Sign in with your linked account in the popup.',
|
||||
@@ -526,6 +528,9 @@ const en = {
|
||||
teams_create_team_hint:
|
||||
'A team pays for the accounts you create in it. You stay its only administrator.',
|
||||
teams_create_team_prompt: 'What should the team be called?',
|
||||
teams_roster: 'Who else is here',
|
||||
teams_roster_hint: 'Everyone on this team. You can share files with any of them.',
|
||||
teams_roster_empty: 'Nobody else yet.',
|
||||
teams_accounts: 'Accounts',
|
||||
teams_no_accounts: 'This team has no accounts yet.',
|
||||
teams_account_of: 'This account belongs to %%',
|
||||
|
||||
Reference in New Issue
Block a user