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
126 changes: 91 additions & 35 deletions apps/dotcom/client/src/pages/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
userHasFlag,
ZStoreData,
} from '@tldraw/dotcom-shared'
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { fetch } from 'tldraw'
import { sentryReleaseName } from '../../sentry-release-name'
Expand Down Expand Up @@ -224,12 +224,11 @@ export function Component() {
Force Reboot
</TlaButton>
</div>
<MigrateUserToGroups
inputRef={inputRef}
<EnrollUserInGroups
user={data.user[0] as TlaUser}
onSuccess={loadData}
onError={setError}
onSuccessMessage={setSuccessMessage}
didMigrate={userHasFlag((data.user[0] as TlaUser).flags, 'groups_backend')}
/>
<StructuredDataDisplay data={data} />
</section>
Expand Down Expand Up @@ -667,43 +666,83 @@ function DownloadTldrFile({ legacy }: { legacy: boolean }) {
)
}

function MigrateUserToGroups({
inputRef,
function EnrollUserInGroups({
user,
onSuccess,
onError,
onSuccessMessage,
didMigrate,
}: {
inputRef: RefObject<HTMLInputElement | null>
user: TlaUser
onSuccess(): void
onError(error: string): void
onSuccessMessage(message: string): void
didMigrate: boolean
}) {
const [isMigrating, setIsMigrating] = useState(false)

const handleMigrate = useCallback(async () => {
const q = inputRef.current?.value?.trim() ?? ''
if (!q) {
onError('Please enter an email or ID')
const [isEnrolling, setIsEnrolling] = useState(false)
const [isUnenrolling, setIsUnenrolling] = useState(false)
// Derive status and the target id from the loaded user, not the live search
// box, so the action always matches the status shown above it.
const hasBackend = userHasFlag(user.flags, 'groups_backend')
const hasFrontend = userHasFlag(user.flags, 'groups_frontend')
const fullyEnrolled = hasBackend && hasFrontend

const handleEnroll = useCallback(async () => {
if (
!window.confirm(
`Enroll ${user.email} in the groups feature? This grants groups_backend (migrating their data if needed) and groups_frontend (the groups UI).`
)
) {
return
}

setIsEnrolling(true)
onError('')

try {
const res = await fetch(
`/api/app/admin/user/enroll_groups?${new URLSearchParams({ q: user.id })}`,
{ method: 'POST' }
)

if (!res.ok) {
onError(res.statusText + ': ' + (await res.text()))
return
}

const result = await res.json()
const changes = [
result.backendMigrated && 'migrated to groups backend',
result.frontendGranted && 'granted groups UI',
].filter(Boolean)
onSuccessMessage(
changes.length
? `Enrolled in groups: ${changes.join(', ')}`
: 'User was already fully enrolled'
)
onSuccess()
} catch (err) {
onError(err instanceof Error ? err.message : 'Enrollment failed')
} finally {
setIsEnrolling(false)
}
}, [user.id, user.email, onError, onSuccess, onSuccessMessage])

const handleUnenroll = useCallback(async () => {
if (
!window.confirm(
`Are you sure you want to migrate user "${q}" to the groups backend? This action cannot be undone.`
`Remove ${user.email} from the groups UI? This clears the groups_frontend flag (their data stays migrated).`
)
) {
return
}

setIsMigrating(true)
setIsUnenrolling(true)
onError('')

try {
const res = await fetch(`/api/app/admin/user/migrate?${new URLSearchParams({ q })}`, {
method: 'POST',
})
const res = await fetch(
`/api/app/admin/user/unenroll_groups?${new URLSearchParams({ q: user.id })}`,
{ method: 'POST' }
)

if (!res.ok) {
onError(res.statusText + ': ' + (await res.text()))
Expand All @@ -712,30 +751,47 @@ function MigrateUserToGroups({

const result = await res.json()
onSuccessMessage(
`User migrated successfully! Files: ${result.files_migrated}, Pinned: ${result.pinned_files_migrated}`
result.frontendRemoved
? 'Removed from the groups UI'
: 'User was not enrolled in the groups UI'
)
onSuccess()
} catch (err) {
onError(err instanceof Error ? err.message : 'Migration failed')
onError(err instanceof Error ? err.message : 'Unenroll failed')
} finally {
setIsMigrating(false)
setIsUnenrolling(false)
}
}, [inputRef, onError, onSuccess, onSuccessMessage])
}, [user.id, user.email, onError, onSuccess, onSuccessMessage])

return didMigrate ? null : (
return (
<div className={styles.migrationSection}>
<h4 className="tla-text_ui__medium">Migrate User to Groups Backend</h4>
<h4 className="tla-text_ui__medium">Groups enrollment</h4>
<p className="tla-text_ui__small">
Migrate this user from the legacy file_state model to the new groups model.
groups_backend: {hasBackend ? '✓ enrolled' : '✗ not enrolled'} · groups_frontend:{' '}
{hasFrontend ? '✓ enrolled' : '✗ not enrolled'}
</p>
<TlaButton
onClick={handleMigrate}
variant="primary"
disabled={isMigrating}
isLoading={isMigrating}
>
{isMigrating ? 'Migrating...' : 'Migrate to Groups'}
</TlaButton>
<p className="tla-text_ui__small">
Enroll this user in the groups feature: migrates their data to the groups model if needed
and shows the groups UI.
</p>
<div className={styles.userActions}>
<TlaButton
onClick={handleEnroll}
variant="primary"
disabled={isEnrolling || isUnenrolling || fullyEnrolled}
isLoading={isEnrolling}
>
Enroll in groups
</TlaButton>
<TlaButton
onClick={handleUnenroll}
variant="secondary"
disabled={isEnrolling || isUnenrolling || !hasFrontend}
isLoading={isUnenrolling}
>
Unenroll from groups UI
</TlaButton>
</div>
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
-webkit-font-smoothing: antialiased;
}

.tlaButton:disabled {
cursor: not-allowed;
opacity: 0.5;
}

.tlaButton > .spinner {
position: absolute;
top: calc(50% - 20px);
Expand Down Expand Up @@ -123,15 +128,15 @@
}

@media (hover: hover) {
.primary:hover {
.primary:not(:disabled):hover {
background-color: var(--tla-color-primary-hover);
}

.secondary:hover {
.secondary:not(:disabled):hover {
background-color: var(--tla-color-secondary-hover);
}

.cta:hover {
.cta:not(:disabled):hover {
background-color: var(--tla-color-primary-hover);
}

Expand Down
86 changes: 85 additions & 1 deletion apps/dotcom/sync-worker/src/TLUserDurableObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,22 @@ import {
ZErrorCode,
ZServerSentPacket,
createMutators,
parseFlags,
schema,
} from '@tldraw/dotcom-shared'
import {
JsonChunkAssembler,
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason,
} from '@tldraw/sync-core'
import { ExecutionQueue, IndexKey, assert, mapObjectMapValues, sleep } from '@tldraw/utils'
import {
ExecutionQueue,
IndexKey,
assert,
mapObjectMapValues,
sleep,
uniqueId,
} from '@tldraw/utils'
import { createSentry } from '@tldraw/worker-shared'
import { DurableObject } from 'cloudflare:workers'
import { IRequest, Router } from 'itty-router'
Expand Down Expand Up @@ -803,6 +811,82 @@ export class TLUserDurableObject extends DurableObject<Environment> {
return result.rows[0]
}

/**
* Enroll a user in the groups feature so they can actually see and use it.
* Ensures both flags:
* - groups_backend: migrate the user's data into the groups model if needed
* (the SQL function is idempotent and returns early if already migrated).
* - groups_frontend: the flag that shows the groups UI (otherwise only granted
* when a user accepts a group invite).
* Then reboots the user so the new flags/data take effect.
*/
async admin_enrollInGroups(userId: string) {
this.userId ??= userId

// 1. Ensure the data model is migrated. The SQL function is idempotent:
// flag_added is false (and nothing changes) when the user is already migrated.
const { rows } = await sql<{
flag_added: boolean
}>`SELECT * FROM migrate_user_to_groups(${userId}, ${uniqueId()})`.execute(this.db)
const backendMigrated = rows[0]?.flag_added ?? false

// 2. Ensure the groups UI flag is present (read flags after the migration,
// which may have just added groups_backend).
const row = await this.db
.selectFrom('user')
.where('id', '=', userId)
.select('flags')
.executeTakeFirst()
const flags = parseFlags(row?.flags)
let frontendGranted = false
if (!flags.includes('groups_frontend')) {
flags.push('groups_frontend')
await this.db
.updateTable('user')
.set({ flags: flags.join(',') })
.where('id', '=', userId)
.execute()
frontendGranted = true
}

// 3. Reboot so the user picks up the new flags and migrated data.
await this.env.USER_DO_SNAPSHOTS.delete(getUserDoSnapshotKey(this.env, userId))
await this.cache?.reboot({ delay: false, source: 'admin', hard: true })

return { backendMigrated, frontendGranted }
}

/**
* Unenroll a user from the groups UI by clearing the groups_frontend flag.
* Leaves groups_backend (the data model) in place — this just hides the groups
* UI again, which is handy for testing the enrollment flow. Reboots the user so
* the change takes effect.
*/
async admin_unenrollFromGroups(userId: string) {
this.userId ??= userId

const row = await this.db
.selectFrom('user')
.where('id', '=', userId)
.select('flags')
.executeTakeFirst()
const flags = parseFlags(row?.flags)
const nextFlags = flags.filter((flag) => flag !== 'groups_frontend')
const frontendRemoved = nextFlags.length !== flags.length
if (frontendRemoved) {
await this.db
.updateTable('user')
.set({ flags: nextFlags.join(',') })
.where('id', '=', userId)
.execute()
}

await this.env.USER_DO_SNAPSHOTS.delete(getUserDoSnapshotKey(this.env, userId))
await this.cache?.reboot({ delay: false, source: 'admin', hard: true })

return { frontendRemoved }
}

async admin_forceHardReboot(userId: string) {
if (this.cache) {
await this.cache?.reboot({ hard: true, delay: false, source: 'admin' })
Expand Down
20 changes: 20 additions & 0 deletions apps/dotcom/sync-worker/src/adminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ export const adminRoutes = createRouter<Environment>()
const result = await user.admin_migrateToGroups(userRow.id, uniqueId())
return json(result)
})
.post('/app/admin/user/enroll_groups', async (res, env) => {
const q = res.query['q']
if (typeof q !== 'string') {
return new Response('Missing query param', { status: 400 })
}
const userRow = await requireUser(env, q)
const user = getUserDurableObject(env, userRow.id)
const result = await user.admin_enrollInGroups(userRow.id)
return json(result)
})
.post('/app/admin/user/unenroll_groups', async (res, env) => {
const q = res.query['q']
if (typeof q !== 'string') {
return new Response('Missing query param', { status: 400 })
}
const userRow = await requireUser(env, q)
const user = getUserDurableObject(env, userRow.id)
const result = await user.admin_unenrollFromGroups(userRow.id)
return json(result)
})
.get('/app/admin/unmigrated_users_count', async (_res, env) => {
const pg = createPostgresConnectionPool(env, '/app/admin/unmigrated_users_count')
return json({ count: await getNumUnmigratedUsers(pg) })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ShapeIndicatorOverlayUtil, TLShapeIndicatorOverlay, Tldraw } from 'tldraw'
import {
createShapeId,
ShapeIndicatorOverlayUtil,
TLShapeIndicatorOverlay,
Tldraw,
toRichText,
} from 'tldraw'
import 'tldraw/tldraw.css'

// There's a guide at the bottom of this file!
Expand Down Expand Up @@ -28,12 +34,17 @@ export default function IndicatorsLogicExample() {
overlayUtils={overlayUtils}
onMount={(editor) => {
if (editor.getCurrentPageShapeIds().size === 0) {
const bottomLeftA = createShapeId()
const bottomLeftB = createShapeId()
editor.createShapes([
{ type: 'geo', x: 100, y: 100 },
{ type: 'geo', x: 500, y: 150 },
{ type: 'geo', x: 100, y: 500 },
{ type: 'geo', x: 500, y: 500 },
{ type: 'geo', x: 500, y: 150, props: { geo: 'ellipse' } },
{ id: bottomLeftA, type: 'geo', x: 100, y: 500 },
{ id: bottomLeftB, type: 'geo', x: 250, y: 400 },
{ type: 'text', x: 500, y: 500, props: { richText: toRichText('Hello, world!') } },
])
editor.groupShapes([bottomLeftA, bottomLeftB])
editor.setSelectedShapes([])
}
}}
/>
Expand Down
6 changes: 4 additions & 2 deletions apps/examples/src/examples/ui/indicators-logic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ keywords:
]
---

Change when indicators are shown and how they appear.
Change when shape indicators are shown and how they appear.

---

This example shows how you can change when indicators are shown and how they appear.
This example shows how you can change when shape indicators are shown and how they appear.

Shape indicators are the lines that are drawn around the border of a shape when hovering over it. This examples makes them appear all of the time.
Loading
Loading