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
98 changes: 71 additions & 27 deletions src/editor/CloudAccountPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ type CloudAccountUser = { id: string; email: string } | null;
type CloudPublishInfo = { ok: true; login: string; pagesBaseUrl: string } | { ok: false; error: string };
type CloudAuthMode = 'login' | 'signup';
export const CLOUD_RETURN_TO_CLOUD_AFTER_AUTH_STORAGE_KEY = 'phaserforge.cloud.return_to_cloud_after_auth';
const CLOUD_ACCOUNT_CREATED_STORAGE_KEY = 'phaserforge.cloud.account_created_v1';
const GITHUB_AUTHORIZED_APPS_SETTINGS_URL = 'https://github.com/settings/connections/applications';

let cachedCloudAccountUser: CloudAccountUser | undefined;
Expand Down Expand Up @@ -50,24 +49,8 @@ export function __resetCloudAccountPanelAuthCacheForTests() {
cachedPublishInfoByUserId.clear();
}

function hasCreatedCloudAccount(): boolean {
try {
return window.localStorage.getItem(CLOUD_ACCOUNT_CREATED_STORAGE_KEY) === '1';
} catch {
return false;
}
}

function getDefaultAuthMode(): CloudAuthMode {
return hasCreatedCloudAccount() ? 'login' : 'signup';
}

function markCloudAccountCreated() {
try {
window.localStorage.setItem(CLOUD_ACCOUNT_CREATED_STORAGE_KEY, '1');
} catch {
// Ignore storage failures and fall back to first-time defaults next load.
}
return 'signup';
}

export function buildGithubStartHref(params: {
Expand Down Expand Up @@ -200,6 +183,7 @@ export function CloudAccountPanel({
const [publishInlineError, setPublishInlineError] = useState('');
const [cloudGameId, setCloudGameId] = useState<string | null>(null);
const [cloudGameLookupResolved, setCloudGameLookupResolved] = useState(false);
const [cloudLinkVerificationPending, setCloudLinkVerificationPending] = useState(false);
const [conflictCheckComplete, setConflictCheckComplete] = useState(true);
const [workspaceConflict, setWorkspaceConflict] = useState<{
cloud: { project: ProjectSpec; updatedAt: string; label: string };
Expand All @@ -217,6 +201,7 @@ export function CloudAccountPanel({

const CLOUD_AUTOSAVE_DEBOUNCE_MS = 1000;
const CLOUD_AUTOSAVE_RETRY_MS = 5000;
const resolvedCloudGameId = cloudGameIdRef.current ?? cloudGameId ?? null;

const githubEnabled = useMemo(() => true, []);
const githubStartHref = useMemo(() => {
Expand Down Expand Up @@ -294,19 +279,75 @@ export function CloudAccountPanel({
cloudGameIdRef.current = cloudGameId;
}, [cloudGameId]);

useEffect(() => {
let cancelled = false;
if (!user) return;
if (!cloudLinkVerificationPending) return;
const linkedCloudGameId = cloudGameIdRef.current ?? cloudGameId ?? null;
if (!linkedCloudGameId) {
setCloudLinkVerificationPending(false);
return;
}

const verifyLinkedCloudGame = async () => {
try {
await getGame(linkedCloudGameId);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message === 'not_found' && !cancelled) {
const project = structuredClone(state.project);
const title = project.publishTitle?.trim() || project.title?.trim() || 'Untitled';
const signature = `${project.id}\n${title}\n${canonicalizeProjectForComparison(project)}`;
appendPersistenceDebugEntry('cloud:stale-cloud-game-link-cleared', {
staleCloudGameId: linkedCloudGameId,
stateProjectId: state.project.id,
});
cloudGameIdRef.current = null;
setCloudGameId(null);
if (state.syncMode === 'online') {
try {
const recreatedCloudGameId = await saveProjectToCloud({
title,
project,
cloudGameId: null,
});
lastAutosavedSignatureRef.current = signature;
appendPersistenceDebugEntry('cloud:stale-cloud-game-link-recreated', {
previousCloudGameId: linkedCloudGameId,
recreatedCloudGameId,
stateProjectId: state.project.id,
});
} catch (recreateError) {
onError(recreateError instanceof Error ? recreateError.message : 'Cloud save failed');
}
}
}
} finally {
if (cancelled) return;
setCloudLinkVerificationPending(false);
}
};

void verifyLinkedCloudGame();
return () => {
cancelled = true;
};
}, [cloudGameId, cloudLinkVerificationPending, state.project.id, user]);

useEffect(() => {
onWorkspaceConflictChange?.(workspaceConflict != null);
}, [onWorkspaceConflictChange, workspaceConflict]);

useEffect(() => {
let cancelled = false;
if (!user) return;
if (cloudLinkVerificationPending) return;
if (!cloudGameLookupResolved) return;
if (hasCheckedConflictRef.current) return;
hasCheckedConflictRef.current = true;
setConflictCheckComplete(false);
appendPersistenceDebugEntry('cloud:conflict-check-start', {
activeCloudGameId: activeCloudGameId ?? cloudGameIdRef.current ?? null,
activeCloudGameId: resolvedCloudGameId,
stateProjectId: state.project.id,
});

Expand Down Expand Up @@ -335,7 +376,7 @@ export function CloudAccountPanel({
project: structuredClone(state.project),
savedAtMs: null as number | null,
};
const mappedCloudGameId = activeCloudGameId ?? cloudGameIdRef.current;
const mappedCloudGameId = resolvedCloudGameId;
if (!mappedCloudGameId) {
appendPersistenceDebugEntry('cloud:conflict-check-skipped-unlinked-project', {
stateProjectId: state.project.id,
Expand Down Expand Up @@ -397,15 +438,15 @@ export function CloudAccountPanel({
);
} catch (error) {
appendPersistenceDebugEntry('cloud:conflict-check-error', {
activeCloudGameId: activeCloudGameId ?? cloudGameIdRef.current ?? null,
activeCloudGameId: resolvedCloudGameId,
stateProjectId: state.project.id,
error,
});
// ignore: conflict UI is best-effort; users can still use manual load/save.
} finally {
if (!cancelled) {
appendPersistenceDebugEntry('cloud:conflict-check-complete', {
activeCloudGameId: activeCloudGameId ?? cloudGameIdRef.current ?? null,
activeCloudGameId: resolvedCloudGameId,
stateProjectId: state.project.id,
});
setConflictCheckComplete(true);
Expand All @@ -417,7 +458,7 @@ export function CloudAccountPanel({
return () => {
cancelled = true;
};
}, [user, activeCloudGameId, cloudGameLookupResolved, state.project.id]);
}, [cloudGameLookupResolved, cloudLinkVerificationPending, state.project.id, user]);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -487,7 +528,7 @@ export function CloudAccountPanel({
publishTitleDraft,
publishRepoDraft,
activeCloudGameId: activeCloudGameId ?? null,
cloudGameId: cloudGameIdRef.current ?? cloudGameId,
cloudGameId: resolvedCloudGameId,
});

useEffect(() => {
Expand Down Expand Up @@ -592,7 +633,7 @@ export function CloudAccountPanel({
setBusy(true);
try {
const res = await runWithCsrfRetry((csrf) => signup(email, password, csrf, inviteToken.trim() || undefined));
markCloudAccountCreated();
setCloudLinkVerificationPending(true);
setCachedCloudAccountUser(res.user);
setUser(res.user);
setAuthResolved(true);
Expand All @@ -611,6 +652,7 @@ export function CloudAccountPanel({
setBusy(true);
try {
const res = await runWithCsrfRetry((csrf) => login(email, password, csrf));
setCloudLinkVerificationPending(true);
setCachedCloudAccountUser(res.user);
setUser(res.user);
setAuthResolved(true);
Expand Down Expand Up @@ -642,6 +684,7 @@ export function CloudAccountPanel({
setShowGithubConfirm(null);
setWorkspaceConflict(null);
setCloudGameId(null);
setCloudLinkVerificationPending(false);
setAuthMode(getDefaultAuthMode());
hasCheckedConflictRef.current = false;
onStatus('Signed out');
Expand Down Expand Up @@ -798,11 +841,12 @@ export function CloudAccountPanel({
if (workspaceConflict) {
clearAutosaveTimer();
}
if (!authResolved || !user || !cloudGameLookupResolved || !conflictCheckComplete || workspaceConflict) {
if (!authResolved || !user || !cloudGameLookupResolved || cloudLinkVerificationPending || !conflictCheckComplete || workspaceConflict) {
appendPersistenceDebugEntry('cloud:autosave-gate-blocked', {
authResolved,
hasUser: Boolean(user),
cloudGameLookupResolved,
cloudLinkVerificationPending,
conflictCheckComplete,
hasWorkspaceConflict: Boolean(workspaceConflict),
stateProjectId: state.project.id,
Expand Down Expand Up @@ -830,7 +874,7 @@ export function CloudAccountPanel({
return () => {
clearAutosaveTimer();
};
}, [authResolved, cloudGameLookupResolved, conflictCheckComplete, state.project, user, workspaceConflict]);
}, [authResolved, cloudGameLookupResolved, cloudLinkVerificationPending, conflictCheckComplete, state.project, user, workspaceConflict]);

useEffect(() => {
if (typeof window === 'undefined') return;
Expand Down
73 changes: 67 additions & 6 deletions tests/editor/cloud-account-publish-gating.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,68 @@ describe('CloudAccountPanel publish gating', () => {
}
});

it('defaults to the Log in tab for returning users who already created an account', async () => {
it('signs up cleanly when a wiped backend leaves the current project linked to a stale cloud game id', async () => {
vi.useFakeTimers();
api.me.mockImplementationOnce(async () => {
throw new Error('not_signed_in');
});
api.signup.mockResolvedValueOnce({ user: { id: 'u1', email: 'dev@example.com' } });
api.getGame.mockRejectedValue(new Error('not_found'));
api.createGame.mockResolvedValueOnce({ game: { id: 'g-recreated', title: 'Untitled', created_at: 'c', updated_at: 'u' } });

const project = createEmptyProject();
project.id = 'project-stale-signup';
project.title = 'Untitled';

const onCloudGameLinked = vi.fn();
const onError = vi.fn();

const view = renderIntoDom(
<CloudAccountPanel
state={{ project, syncMode: 'online' }}
activeCloudGameId="g-stale"
dispatch={() => {}}
onLoadYaml={() => {}}
onCloudGameLinked={onCloudGameLinked}
onStatus={() => {}}
onError={onError}
/>,
);

try {
await flushEffects();

const form = document.querySelector('.cloud-auth-form') as HTMLFormElement | null;
const emailInput = document.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = document.querySelector('input[name="password"]') as HTMLInputElement | null;
const inviteInput = document.querySelector('input[aria-label="Invite code"]') as HTMLInputElement | null;

fireEvent.change(emailInput as HTMLInputElement, { target: { value: 'dev@example.com' } });
fireEvent.change(passwordInput as HTMLInputElement, { target: { value: 'hunter2' } });
fireEvent.change(inviteInput as HTMLInputElement, { target: { value: 'invite-code-123' } });

await act(async () => {
fireEvent.submit(form as HTMLFormElement);
await vi.runAllTimersAsync();
});

expect(api.signup).toHaveBeenCalledWith('dev@example.com', 'hunter2', 'csrf', 'invite-code-123');
expect(api.createGame).toHaveBeenCalledWith(
'Untitled',
expect.objectContaining({
id: 'project-stale-signup',
title: 'Untitled',
}),
'csrf',
);
expect(onCloudGameLinked).toHaveBeenCalledWith('g-recreated');
expect(onError).not.toHaveBeenCalledWith('not_found');
} finally {
view.cleanup();
}
});

it('still defaults to the Create tab even if a stale local account-created hint exists', async () => {
api.me.mockImplementationOnce(async () => {
throw new Error('not_signed_in');
});
Expand All @@ -799,11 +860,11 @@ describe('CloudAccountPanel publish gating', () => {
);
try {
await flushEffects();
expect(document.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain('Log in');
expect(document.querySelector('[aria-label="Invite code"]')).toBeFalsy();
expect(document.body.textContent).toContain('Log in to access your cloud projects and publishing tools.');
expect(document.querySelector('[data-testid="cloud-account-submit"]')?.textContent).toContain('Log in');
expect(document.body.textContent).toContain('Create');
expect(document.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain('Create');
expect(document.querySelector('[aria-label="Invite code"]')).toBeTruthy();
expect(document.body.textContent).toContain('Create your account with your invite code.');
expect(document.querySelector('[data-testid="cloud-account-submit"]')?.textContent).toContain('Create account');
expect(document.body.textContent).toContain('Already have an account?');
} finally {
view.cleanup();
}
Expand Down
Loading