feat(web): automatic re-attach on connect + AccountPanel fallback action (re-attach spec §3.4)
This commit is contained in:
+66
-7
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import type { User } from '@backspace/shared';
|
||||
|
||||
// ── Store mocks ─────────────────────────────────────────────────────────────
|
||||
@@ -7,20 +7,27 @@ import type { User } from '@backspace/shared';
|
||||
// that user through a mutable fixture and mock the store with a selector-aware
|
||||
// callable (mirrors the selector-mock idiom used across the web test suite).
|
||||
let currentUser: User | null = null;
|
||||
// Instances backing the re-attach fallback action — mutated per test.
|
||||
let currentInstances: unknown[] = [];
|
||||
const noop = vi.fn();
|
||||
const setUserMock = vi.fn();
|
||||
// The peer re-attach call (primary `api.users.reattach`), asserted by the
|
||||
// two-step-confirm test.
|
||||
const mockReattach = vi.fn();
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
updateProfile: (...args: unknown[]) => unknown;
|
||||
changePassword: (...args: unknown[]) => unknown;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
vi.mock('../../../stores/authStore', () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector: (s: AuthState) => unknown) =>
|
||||
selector({ user: currentUser, updateProfile: noop, changePassword: noop }),
|
||||
selector({ user: currentUser, updateProfile: noop, changePassword: noop, setUser: setUserMock }),
|
||||
{
|
||||
getState: (): AuthState => ({ user: currentUser, updateProfile: noop, changePassword: noop }),
|
||||
getState: (): AuthState => ({ user: currentUser, updateProfile: noop, changePassword: noop, setUser: setUserMock }),
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
},
|
||||
@@ -37,8 +44,8 @@ vi.mock('../../../stores/uiStore', () => ({
|
||||
|
||||
vi.mock('../../../stores/instanceStore', () => ({
|
||||
useInstanceStore: Object.assign(
|
||||
(selector: (s: { instances: unknown[] }) => unknown) => selector({ instances: [] }),
|
||||
{ getState: () => ({ instances: [] }), setState: vi.fn(), subscribe: vi.fn() },
|
||||
(selector: (s: { instances: unknown[] }) => unknown) => selector({ instances: currentInstances }),
|
||||
{ getState: () => ({ instances: currentInstances }), setState: vi.fn(), subscribe: vi.fn() },
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -49,9 +56,12 @@ vi.mock('../../../stores/transferStore', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// api.uploads.url is referenced during render for avatar/banner sources.
|
||||
// api.uploads.url is referenced during render for avatar/banner sources;
|
||||
// api.users.reattach is the peer call the fallback action fires on confirm.
|
||||
vi.mock('../../../api/client', () => ({
|
||||
api: { uploads: { url: (f: string) => `/api/uploads/${f}` } },
|
||||
// reattach is wrapped so the top-level `mockReattach` const is dereferenced
|
||||
// lazily at call time (vi.mock factories are hoisted above const init).
|
||||
api: { uploads: { url: (f: string) => `/api/uploads/${f}` }, users: { reattach: (...args: unknown[]) => mockReattach(...args) } },
|
||||
}));
|
||||
|
||||
// Child modals are closed in these render cases; stub them so their transitive
|
||||
@@ -85,9 +95,27 @@ function makeUser(overrides: Partial<User> = {}): User {
|
||||
|
||||
const NOTICE = /This account is detached from its home instance\./i;
|
||||
|
||||
// A connected home-domain instance carrying the proof-mint API surface the
|
||||
// fallback action calls. Only the fields AccountPanel touches are populated.
|
||||
function makeHomeConnection(overrides: {
|
||||
origin?: string;
|
||||
username?: string;
|
||||
attachProof?: ReturnType<typeof vi.fn>;
|
||||
} = {}) {
|
||||
return {
|
||||
origin: overrides.origin ?? 'https://orbit.test',
|
||||
username: overrides.username ?? 'youruser',
|
||||
status: 'connected' as const,
|
||||
api: { auth: { attachProof: overrides.attachProof ?? vi.fn() } },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
currentUser = null;
|
||||
currentInstances = [];
|
||||
setUserMock.mockReset();
|
||||
mockReattach.mockReset();
|
||||
});
|
||||
|
||||
describe('AccountPanel detached-account notice', () => {
|
||||
@@ -111,3 +139,34 @@ describe('AccountPanel detached-account notice', () => {
|
||||
expect(screen.queryByText(NOTICE)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AccountPanel re-attach fallback action', () => {
|
||||
it('shows the re-attach action when a connection to the home domain exists', () => {
|
||||
currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' });
|
||||
currentInstances = [makeHomeConnection()];
|
||||
render(<AccountPanel />);
|
||||
expect(screen.getByRole('button', { name: /re-attach to orbit\.test/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the re-attach action without a home-domain connection', () => {
|
||||
currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' });
|
||||
currentInstances = [];
|
||||
render(<AccountPanel />);
|
||||
expect(screen.queryByRole('button', { name: /re-attach/i })).not.toBeInTheDocument();
|
||||
// Informational copy still present:
|
||||
expect(screen.getByText(/detached from its home instance/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('two-step confirm: first click arms, second click mints proof and calls reattach', async () => {
|
||||
currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' });
|
||||
const attachProof = vi.fn().mockResolvedValue({ token: 'a'.repeat(64) });
|
||||
currentInstances = [makeHomeConnection({ attachProof })];
|
||||
mockReattach.mockResolvedValue({ success: true, user: makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' }) });
|
||||
|
||||
render(<AccountPanel />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /re-attach to orbit\.test/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /confirm re-attach/i }));
|
||||
await waitFor(() => expect(mockReattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) }));
|
||||
expect(attachProof).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useAuthStore } from '../../../stores/authStore';
|
||||
import { useUIStore } from '../../../stores/uiStore';
|
||||
import { useInstanceStore } from '../../../stores/instanceStore';
|
||||
@@ -77,6 +77,45 @@ export function AccountPanel() {
|
||||
const instances = useInstanceStore((s) => s.instances);
|
||||
const changePassword = useAuthStore((s) => s.changePassword);
|
||||
|
||||
// ── Detached-account re-attach (fallback path, re-attach spec §3.4) ──
|
||||
// Explicit action shown only when this client also holds an active connection
|
||||
// to the account's home domain. Two-step armed confirm names both identities
|
||||
// before minting the proof. The primary/automatic path lives in instanceStore.
|
||||
const [reattachArmed, setReattachArmed] = useState(false);
|
||||
const [reattaching, setReattaching] = useState(false);
|
||||
const [reattachError, setReattachError] = useState<string | null>(null);
|
||||
|
||||
const homeConnection = useMemo(() => {
|
||||
if (!user?.homeInstance) return null;
|
||||
const homeDomain = user.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
|
||||
return instances.find(
|
||||
(i) => i.status === 'connected'
|
||||
&& i.origin.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase() === homeDomain,
|
||||
) ?? null;
|
||||
}, [instances, user?.homeInstance]);
|
||||
|
||||
const handleReattach = async () => {
|
||||
if (!homeConnection) return;
|
||||
if (!reattachArmed) {
|
||||
setReattachArmed(true);
|
||||
return;
|
||||
}
|
||||
setReattaching(true);
|
||||
setReattachError(null);
|
||||
try {
|
||||
// Target domain = THIS instance (where the detached account lives).
|
||||
const { token } = await homeConnection.api.auth.attachProof(window.location.host);
|
||||
const res = await api.users.reattach({ token });
|
||||
useAuthStore.getState().setUser(res.user);
|
||||
addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000);
|
||||
} catch (err) {
|
||||
setReattachError(err instanceof Error ? err.message : 'Re-attach failed');
|
||||
} finally {
|
||||
setReattaching(false);
|
||||
setReattachArmed(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const effectiveDisplayName = displayName.trim() || user.username;
|
||||
@@ -269,6 +308,25 @@ export function AccountPanel() {
|
||||
<span className="font-medium text-txt-primary">This account is detached from its home instance.</span>{' '}
|
||||
{user.homeInstance} was reset or is no longer available, so this account now operates locally on
|
||||
this instance — your profile and password are managed here.
|
||||
{homeConnection && (
|
||||
<>
|
||||
{' '}As <span className="font-medium text-txt-primary">{homeConnection.username}</span> on{' '}
|
||||
{user.homeInstance}, you can re-link this account — profile and presence will sync from there again.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReattach}
|
||||
disabled={reattaching}
|
||||
className="mt-2 block rounded-md bg-accent-amber/20 hover:bg-accent-amber/30 disabled:opacity-50 text-txt-primary px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
>
|
||||
{reattaching
|
||||
? 'Re-attaching…'
|
||||
: reattachArmed
|
||||
? `Confirm re-attach as ${homeConnection.username}`
|
||||
: `Re-attach to ${user.homeInstance}`}
|
||||
</button>
|
||||
{reattachError && <div className="mt-1.5 text-accent-rose">{reattachError}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* ── Profile Customization ── */}
|
||||
|
||||
Reference in New Issue
Block a user