Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions apps/dotcom/client/public/tla/locales-compiled/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,12 @@
"value": "."
}
],
"858ba4765e": [
{
"type": 0,
"value": "Member"
}
],
"85a082de1b": [
{
"type": 0,
Expand Down Expand Up @@ -1277,12 +1283,6 @@
"value": "Legal summary"
}
],
"e3afed0047": [
{
"type": 0,
"value": "Admin"
}
],
"e404559826": [
{
"type": 0,
Expand Down
6 changes: 3 additions & 3 deletions apps/dotcom/client/public/tla/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@
"815e116a2e": {
"translation": "Have a bug, issue, or idea for tldraw? Let us know! Fill out this form and we will follow up over email if needed. You can also <discord>chat with us on Discord</discord> or <github>submit an issue on GitHub</github>."
},
"858ba4765e": {
"translation": "Member"
},
"85a082de1b": {
"translation": "Please refresh the page to get the latest version of tldraw."
},
Expand Down Expand Up @@ -549,9 +552,6 @@
"e2ae80a510": {
"translation": "Legal summary"
},
"e3afed0047": {
"translation": "Admin"
},
"e404559826": {
"translation": "Terms of service"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const messages = defineMessages({
copyInviteLink: { defaultMessage: 'Copy invite link' },
members: { defaultMessage: 'Members' },
owner: { defaultMessage: 'Owner' },
admin: { defaultMessage: 'Admin' },
member: { defaultMessage: 'Member' },
you: { defaultMessage: 'you' },
dangerZone: { defaultMessage: 'Danger zone' },
leaveGroup: { defaultMessage: 'Leave group…' },
Expand Down Expand Up @@ -62,7 +62,7 @@ export function GroupSettingsDialog({ groupId, onClose }: GroupSettingsDialogPro

const namePlaceholderMsg = useMsg(messages.namePlaceholder)
const ownerMsg = useMsg(messages.owner)
const adminMsg = useMsg(messages.admin)
const memberMsg = useMsg(messages.member)
const youMsg = useMsg(messages.you)

// Get group data
Expand All @@ -83,7 +83,7 @@ export function GroupSettingsDialog({ groupId, onClose }: GroupSettingsDialogPro
// Leaving is allowed for everyone except the last owner — a group invariant
// (it must always keep at least one owner), not a capability.
const canLeave = role !== 'owner' || ownersCount > 1
const roleLabels: Record<Role, string> = { owner: ownerMsg, admin: adminMsg }
const roleLabels: Record<Role, string> = { owner: ownerMsg, member: memberMsg }

const handleCopyInviteLink = async () => {
if (copiedInviteLink) return
Expand Down
2 changes: 1 addition & 1 deletion apps/dotcom/sync-worker/src/routes/tla/acceptInvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export async function acceptInvite(request: IRequest, env: Environment): Promise
userId: auth.userId,
userColor: user.color || '#000000',
userName: user.name,
role: 'admin',
role: 'member',
index,
createdAt: Date.now(),
updatedAt: Date.now(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Rename the non-owner group role from 'admin' to 'member'.
-- The 'admin' label was misleading: the role confers no admin-level
-- capabilities, it's a standard group member.

-- Drop the existing CHECK constraint. It was created as an inline column
-- constraint in 023_groups.sql, so Postgres auto-named it "group_user_role_check".
ALTER TABLE public."group_user" DROP CONSTRAINT IF EXISTS "group_user_role_check";

-- Rewrite existing rows to the new role value.
UPDATE public."group_user" SET "role" = 'member' WHERE "role" = 'admin';

-- Re-add the CHECK constraint with the new allowed values.
ALTER TABLE public."group_user" ADD CONSTRAINT "group_user_role_check" CHECK ("role" IN ('member', 'owner'));
42 changes: 21 additions & 21 deletions packages/dotcom-shared/src/mutators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function makeGroupUser(
return {
createdAt: 1,
updatedAt: 1,
role: 'admin',
role: 'member',
userName: 'Test',
userColor: '#000',
index: 'a1' as IndexKey,
Expand Down Expand Up @@ -386,7 +386,7 @@ describe('file mutations', () => {
file: [] as TlaFile[],
file_state: [] as TlaFileState[],
group: [makeGroup({ id: groupId })],
group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [] as TlaGroupFile[],
}
}
Expand Down Expand Up @@ -651,14 +651,14 @@ describe('group mutations', () => {
await expectValid(() => m.updateGroup(tx, { id: groupId, name: 'Renamed' }))
})

it('admin cannot update group name', async () => {
it('member cannot update group name', async () => {
const groupId = 'group_aaa11112222bbb'
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [],
}
const { tx } = createMockTx(s)
Expand Down Expand Up @@ -692,7 +692,7 @@ describe('group mutations', () => {
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [],
}
const { tx } = createMockTx(s)
Expand All @@ -714,7 +714,7 @@ describe('membership', () => {
group: [makeGroup({ id: groupId })],
group_user: [
makeGroupUser({ userId, groupId, role: 'owner' }),
makeGroupUser({ userId: memberId, groupId, role: 'admin' }),
makeGroupUser({ userId: memberId, groupId, role: 'member' }),
],
group_file: [],
}
Expand All @@ -724,15 +724,15 @@ describe('membership', () => {
expect(s.group_user.find((gu) => gu.userId === memberId)?.role).toBe('owner')
})

it('admin cannot set member roles', async () => {
it('member cannot set member roles', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
group_user: [
makeGroupUser({ userId, groupId, role: 'admin' }),
makeGroupUser({ userId: memberId, groupId, role: 'admin' }),
makeGroupUser({ userId, groupId, role: 'member' }),
makeGroupUser({ userId: memberId, groupId, role: 'member' }),
],
group_file: [],
}
Expand All @@ -743,22 +743,22 @@ describe('membership', () => {
)
})

it('cannot demote last owner to admin', async () => {
it('cannot demote last owner to member', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
group_user: [
makeGroupUser({ userId, groupId, role: 'owner' }),
makeGroupUser({ userId: memberId, groupId, role: 'admin' }),
makeGroupUser({ userId: memberId, groupId, role: 'member' }),
],
group_file: [],
}
const { tx } = createMockTx(s)
const m = createMutators(userId)
await expectForbidden(() =>
m.setGroupMemberRole(tx, { groupId, targetUserId: userId, role: 'admin' })
m.setGroupMemberRole(tx, { groupId, targetUserId: userId, role: 'member' })
)
})

Expand All @@ -784,7 +784,7 @@ describe('membership', () => {
group: [makeGroup({ id: groupId })],
group_user: [
makeGroupUser({ userId: 'user_owner12345678ab', groupId, role: 'owner' }),
makeGroupUser({ userId, groupId, role: 'admin' }),
makeGroupUser({ userId, groupId, role: 'member' }),
],
group_file: [],
}
Expand Down Expand Up @@ -847,13 +847,13 @@ describe('file operations across groups', () => {
await expectForbidden(() => m.moveFileToGroup(tx, { fileId, groupId: groupB }))
})

it('admin can remove file from group', async () => {
it('member can remove file from group', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [makeFile({ id: fileId, owningGroupId: groupA })],
file_state: [makeFileState({ userId, fileId })],
group: [makeGroup({ id: groupA })],
group_user: [makeGroupUser({ userId, groupId: groupA, role: 'admin' })],
group_user: [makeGroupUser({ userId, groupId: groupA, role: 'member' })],
group_file: [makeGroupFile({ fileId, groupId: groupA })],
}
const { tx } = createMockTx(s)
Expand Down Expand Up @@ -959,13 +959,13 @@ describe('regenerateGroupInviteSecret', () => {
const userId = 'user_aaaa11112222bbbb'
const groupId = 'group_aaa11112222bbb'

it('admin can regenerate invite secret', async () => {
it('member can regenerate invite secret', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId, inviteSecret: 'old_secret_1234567' })],
group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [],
}
const { tx } = createMockTx(s, { location: 'server' })
Expand All @@ -976,18 +976,18 @@ describe('regenerateGroupInviteSecret', () => {
})

it('non-member cannot regenerate invite secret', async () => {
const nonAdminId = 'user_nonadmin1234567'
const nonMemberId = 'user_nonmember123456'
const s = {
user: [makeUser({ id: nonAdminId, flags: 'groups_backend' })],
user: [makeUser({ id: nonMemberId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
// No group_user for nonAdminId — not a member at all
// No group_user for nonMemberId — not a member at all
group_user: [],
group_file: [],
}
const { tx } = createMockTx(s, { location: 'server' })
const m = createMutators(nonAdminId)
const m = createMutators(nonMemberId)
await expectForbidden(() => m.regenerateGroupInviteSecret(tx, { id: groupId }))
})
})
Expand Down
28 changes: 14 additions & 14 deletions packages/dotcom-shared/src/roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,29 @@ describe('can', () => {
}
})

it('grants admins the non-administrative capabilities only', () => {
const adminCapabilities = capabilities.filter((capability) => can('admin', capability))
expect(adminCapabilities).toEqual(['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'])
it('grants members the non-administrative capabilities only', () => {
const memberCapabilities = capabilities.filter((capability) => can('member', capability))
expect(memberCapabilities).toEqual(['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'])
})

it('denies admins the administrative capabilities', () => {
expect(can('admin', 'editGroup')).toBe(false)
expect(can('admin', 'editMembers')).toBe(false)
expect(can('admin', 'deleteGroup')).toBe(false)
it('denies members the administrative capabilities', () => {
expect(can('member', 'editGroup')).toBe(false)
expect(can('member', 'editMembers')).toBe(false)
expect(can('member', 'deleteGroup')).toBe(false)
})

it("admins' capabilities are a subset of owners'", () => {
it("members' capabilities are a subset of owners'", () => {
for (const capability of capabilities) {
if (can('admin', capability)) {
if (can('member', capability)) {
expect(can('owner', capability)).toBe(true)
}
}
})

it('denies unknown, empty, or missing roles every capability', () => {
// `member` is included deliberately: the rename from `admin` is still
// pending, so it is not yet a valid role.
const notRoles = [null, undefined, '', 'member', 'nope', 'OWNER', 'toString', '__proto__']
// `admin` is included deliberately: it was renamed to `member`, so it is no
// longer a valid role.
const notRoles = [null, undefined, '', 'admin', 'nope', 'OWNER', 'toString', '__proto__']
for (const role of notRoles) {
for (const capability of capabilities) {
expect(can(role, capability)).toBe(false)
Expand All @@ -58,11 +58,11 @@ describe('can', () => {
describe('isRole', () => {
it('accepts known role strings', () => {
expect(isRole('owner')).toBe(true)
expect(isRole('admin')).toBe(true)
expect(isRole('member')).toBe(true)
})

it('rejects unknown, empty, or missing values', () => {
for (const value of [null, undefined, '', 'member', 'nope', 'toString', '__proto__']) {
for (const value of [null, undefined, '', 'admin', 'nope', 'toString', '__proto__']) {
expect(isRole(value)).toBe(false)
}
})
Expand Down
10 changes: 2 additions & 8 deletions packages/dotcom-shared/src/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,16 @@ import { Capability } from './capabilities'
*
* The role is stored in the DB as a plain string (`group_user.role`);
* capabilities are never persisted — they're derived from that string here.
*
* NOTE: `'admin'` is in the process of being renamed to `'member'`. No
* authorization logic branches on that name — the only role literals left in
* logic are the last-owner invariant, which checks `'owner'` (not renamed). So
* the rename is relabeling this key, the `acceptInvite` default, the display
* labels, and a data migration of stored values.
*/

/**
* What each role can do — the single source of truth. The role name is the key,
* and {@link Role} is derived from these keys. Today the only difference between
* `admin` and `owner` is the three administrative capabilities at the end of the
* `member` and `owner` is the three administrative capabilities at the end of the
* owner list.
*/
const roles = {
admin: ['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'],
member: ['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'],
owner: [
'accessFiles',
'addFiles',
Expand Down
27 changes: 24 additions & 3 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4029,16 +4029,34 @@ export class Editor extends EventEmitter<TLEventMap> {

this.once('stop-camera-animation', cancel)

const dirZ = direction.z ?? 0

const moveCamera = (elapsed: number) => {
const { x: cx, y: cy, z: cz } = this.getCamera()
const movementVec = Vec.Mul(direction, (currentSpeed * elapsed) / cz)

// Pan movement from x/y direction
const dx = (direction.x * (currentSpeed * elapsed)) / cz
const dy = (direction.y * (currentSpeed * elapsed)) / cz

let newCx = cx + dx
let newCy = cy + dy
let newCz = cz

// animate zoom if z direction is passed in
if (dirZ !== 0) {
newCz = cz * (1 + dirZ * currentSpeed * elapsed)
// Adjust x/y to keep the viewport center fixed while zooming
const center = this.getViewportScreenCenter()
newCx += center.x / newCz - center.x / cz
newCy += center.y / newCz - center.y / cz
}

// Apply friction
currentSpeed *= 1 - friction
if (currentSpeed < speedThreshold) {
cancel()
} else {
this._setCamera(new Vec(cx + movementVec.x, cy + movementVec.y, cz))
this._setCamera(new Vec(newCx, newCy, newCz))
}
}

Expand Down Expand Up @@ -11332,7 +11350,10 @@ export class Editor extends EventEmitter<TLEventMap> {
}

if (slideSpeed > 0) {
this.slideCamera({ speed: slideSpeed, direction: slideDirection })
this.slideCamera({
speed: slideSpeed,
direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
})
}
} else {
if (info.button === STYLUS_ERASER_BUTTON) {
Expand Down
Loading
Loading