feat: add space visibility/description, instance re-auth, and per-channel permissions
- Accept visibility and description fields when creating spaces - Register creator in connectionManager on space creation for immediate WS broadcasts - Return per-channel myPermissions and space-level myPermissions from GET /spaces/:id - Populate permission maps in spaceStore from REST response - Add reauthenticateInstance flow for tokenless federation placeholders - Handle expired/missing tokens gracefully in autoConnectAll with visible error state - Guard syncInstanceList against premature runs before autoConnectAll completes
This commit is contained in:
@@ -54,7 +54,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
|
app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
|
||||||
preHandler: authenticate,
|
preHandler: authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { name, icon } = request.body;
|
const { name, icon, visibility, description } = request.body;
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
|
||||||
@@ -65,6 +65,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
|
return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate visibility
|
||||||
|
const validVisibilities = ['public', 'request', 'private'];
|
||||||
|
const safeVisibility = visibility && validVisibilities.includes(visibility) ? visibility : 'private';
|
||||||
|
|
||||||
|
// Validate description
|
||||||
|
const safeDescription = description ? description.trim().slice(0, 200) || null : null;
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const spaceId = generateSnowflake();
|
const spaceId = generateSnowflake();
|
||||||
const channelId = generateSnowflake();
|
const channelId = generateSnowflake();
|
||||||
@@ -79,6 +86,8 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
ownerId: request.userId,
|
ownerId: request.userId,
|
||||||
inviteCode,
|
inviteCode,
|
||||||
|
visibility: safeVisibility,
|
||||||
|
description: safeDescription,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
@@ -114,6 +123,9 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(500).send({ error: 'Failed to create space', statusCode: 500 });
|
return reply.code(500).send({ error: 'Failed to create space', statusCode: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register the creator in connectionManager so they receive WS broadcasts for this space
|
||||||
|
connectionManager.addUserSpace(request.userId, spaceId);
|
||||||
|
|
||||||
return reply.code(201).send(rowToSpace(server));
|
return reply.code(201).send(rowToSpace(server));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,15 +228,24 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
.filter((m): m is MemberWithUser => m !== null);
|
.filter((m): m is MemberWithUser => m !== null);
|
||||||
|
|
||||||
// Filter channels by VIEW_CHANNEL permission before returning
|
// Compute space-level permissions for the requesting user
|
||||||
const visibleChannels = channels.filter(ch => {
|
const spacePerms = computePermissions(request.userId, id);
|
||||||
|
|
||||||
|
// Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions
|
||||||
|
const visibleChannels: (Channel & { myPermissions: string })[] = [];
|
||||||
|
for (const ch of channels) {
|
||||||
const perms = computePermissions(request.userId, id, ch.id);
|
const perms = computePermissions(request.userId, id, ch.id);
|
||||||
return (perms & PermissionBits.VIEW_CHANNEL) !== 0n;
|
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||||
|
visibleChannels.push({
|
||||||
|
...rowToChannel(ch),
|
||||||
|
myPermissions: permissionsToString(perms),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result: SpaceWithChannelsAndMembers = {
|
const result: SpaceWithChannelsAndMembers = {
|
||||||
...rowToSpace(server),
|
...rowToSpace(server),
|
||||||
channels: visibleChannels.map(rowToChannel),
|
channels: visibleChannels,
|
||||||
members,
|
members,
|
||||||
roles: roles.map(r => ({
|
roles: roles.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -234,6 +255,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
position: r.position ?? 0,
|
position: r.position ?? 0,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
})),
|
})),
|
||||||
|
myPermissions: permissionsToString(spacePerms),
|
||||||
};
|
};
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
|
|||||||
@@ -300,6 +300,8 @@ export interface AuthResponse {
|
|||||||
export interface CreateSpaceRequest {
|
export interface CreateSpaceRequest {
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
visibility?: SpaceVisibility;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateChannelRequest {
|
export interface CreateChannelRequest {
|
||||||
|
|||||||
@@ -262,10 +262,122 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
|||||||
|
|
||||||
// ─── Main component ──────────────────────────────────────────────────────────
|
// ─── Main component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ConnectedInstances() {
|
function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').ConnectedInstance }) {
|
||||||
const instances = useInstanceStore((s) => s.instances);
|
|
||||||
const removeInstance = useInstanceStore((s) => s.removeInstance);
|
const removeInstance = useInstanceStore((s) => s.removeInstance);
|
||||||
const reconnectInstance = useInstanceStore((s) => s.reconnectInstance);
|
const reconnectInstance = useInstanceStore((s) => s.reconnectInstance);
|
||||||
|
const reauthenticateInstance = useInstanceStore((s) => s.reauthenticateInstance);
|
||||||
|
|
||||||
|
const [showReauth, setShowReauth] = useState(false);
|
||||||
|
const [reauthPassword, setReauthPassword] = useState('');
|
||||||
|
const [reauthLoading, setReauthLoading] = useState(false);
|
||||||
|
const [reauthError, setReauthError] = useState('');
|
||||||
|
|
||||||
|
const isTokenless = !inst.token;
|
||||||
|
|
||||||
|
const handleReauth = async () => {
|
||||||
|
if (!reauthPassword) return;
|
||||||
|
setReauthError('');
|
||||||
|
setReauthLoading(true);
|
||||||
|
try {
|
||||||
|
await reauthenticateInstance(inst.origin, reauthPassword);
|
||||||
|
setShowReauth(false);
|
||||||
|
setReauthPassword('');
|
||||||
|
} catch (err) {
|
||||||
|
setReauthError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setReauthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-surface-channel rounded-lg space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<StatusDot status={inst.status} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm text-txt-primary font-medium truncate">
|
||||||
|
{inst.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-txt-tertiary truncate">
|
||||||
|
{new URL(inst.origin).host}
|
||||||
|
{inst.username && (
|
||||||
|
<span className="ml-1 text-txt-quaternary">as {inst.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(inst.status === 'disconnected' || inst.status === 'error') && inst.error && (
|
||||||
|
<div className="text-xs text-accent-amber mt-0.5">{inst.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||||
|
{(inst.status === 'disconnected' || inst.status === 'error') && (
|
||||||
|
isTokenless ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReauth(!showReauth)}
|
||||||
|
className="px-2 py-1 text-xs text-accent-primary hover:bg-accent-primary/10 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Re-authenticate
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => reconnectInstance(inst.origin)}
|
||||||
|
className="px-2 py-1 text-xs text-accent-primary hover:bg-accent-primary/10 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => removeInstance(inst.origin)}
|
||||||
|
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||||
|
title="Disconnect"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline re-authentication prompt */}
|
||||||
|
{showReauth && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={reauthPassword}
|
||||||
|
onChange={(e) => setReauthPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !reauthLoading && reauthPassword && handleReauth()}
|
||||||
|
placeholder="Your account password"
|
||||||
|
className="flex-1 px-3 py-1.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
||||||
|
disabled={reauthLoading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleReauth}
|
||||||
|
disabled={reauthLoading || !reauthPassword}
|
||||||
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reauthLoading ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowReauth(false); setReauthPassword(''); setReauthError(''); }}
|
||||||
|
className="px-2 py-1.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reauthError && (
|
||||||
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-xs">
|
||||||
|
{reauthError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectedInstances() {
|
||||||
|
const instances = useInstanceStore((s) => s.instances);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -295,43 +407,7 @@ export function ConnectedInstances() {
|
|||||||
|
|
||||||
{/* Remote instances */}
|
{/* Remote instances */}
|
||||||
{instances.map((inst) => (
|
{instances.map((inst) => (
|
||||||
<div key={inst.origin} className="flex items-center justify-between p-3 bg-surface-channel rounded-lg">
|
<InstanceRow key={inst.origin} inst={inst} />
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<StatusDot status={inst.status} />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm text-txt-primary font-medium truncate">
|
|
||||||
{inst.label}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-txt-tertiary truncate">
|
|
||||||
{new URL(inst.origin).host}
|
|
||||||
{inst.username && (
|
|
||||||
<span className="ml-1 text-txt-quaternary">as {inst.username}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{inst.status === 'disconnected' && inst.error && (
|
|
||||||
<div className="text-xs text-accent-amber mt-0.5">{inst.error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
|
||||||
{(inst.status === 'disconnected' || inst.status === 'error') && (
|
|
||||||
<button
|
|
||||||
onClick={() => reconnectInstance(inst.origin)}
|
|
||||||
className="px-2 py-1 text-xs text-accent-primary hover:bg-accent-primary/10 rounded transition-colors"
|
|
||||||
title="Reconnect"
|
|
||||||
>
|
|
||||||
Reconnect
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => removeInstance(inst.origin)}
|
|
||||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
|
||||||
title="Disconnect"
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add instance button / flow */}
|
{/* Add instance button / flow */}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ function loadCachedTokens(): Record<string, CachedInstanceToken> {
|
|||||||
function saveCachedTokens(instances: ConnectedInstance[]): void {
|
function saveCachedTokens(instances: ConnectedInstance[]): void {
|
||||||
const cache: Record<string, CachedInstanceToken> = {};
|
const cache: Record<string, CachedInstanceToken> = {};
|
||||||
for (const inst of instances) {
|
for (const inst of instances) {
|
||||||
|
// Skip tokenless placeholders — writing an empty token would cause
|
||||||
|
// autoConnectAll to find a truthy cached entry with an empty bearer token
|
||||||
|
if (!inst.token) continue;
|
||||||
cache[inst.origin] = {
|
cache[inst.origin] = {
|
||||||
token: inst.token,
|
token: inst.token,
|
||||||
label: inst.label,
|
label: inst.label,
|
||||||
@@ -94,6 +97,7 @@ interface InstanceState {
|
|||||||
instances: ConnectedInstance[];
|
instances: ConnectedInstance[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
_autoConnectDone: boolean;
|
||||||
|
|
||||||
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
|
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
|
||||||
connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
|
connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
|
||||||
@@ -101,6 +105,7 @@ interface InstanceState {
|
|||||||
removeInstance: (origin: string) => void;
|
removeInstance: (origin: string) => void;
|
||||||
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
|
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
|
||||||
reconnectInstance: (origin: string) => Promise<void>;
|
reconnectInstance: (origin: string) => Promise<void>;
|
||||||
|
reauthenticateInstance: (origin: string, password: string) => Promise<void>;
|
||||||
syncInstanceList: () => Promise<void>;
|
syncInstanceList: () => Promise<void>;
|
||||||
autoConnectAll: () => Promise<void>;
|
autoConnectAll: () => Promise<void>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -110,6 +115,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
instances: [],
|
instances: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
_autoConnectDone: false,
|
||||||
|
|
||||||
probeInstance: async (url: string) => {
|
probeInstance: async (url: string) => {
|
||||||
const origin = normalizeOrigin(url);
|
const origin = normalizeOrigin(url);
|
||||||
@@ -293,6 +299,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
const inst = get().instances.find(i => i.origin === origin);
|
const inst = get().instances.find(i => i.origin === origin);
|
||||||
if (!inst || inst.status === 'connected' || inst.status === 'connecting') return;
|
if (!inst || inst.status === 'connected' || inst.status === 'connecting') return;
|
||||||
|
|
||||||
|
// Tokenless placeholders can't reconnect — they need full re-authentication
|
||||||
|
if (!inst.token) return;
|
||||||
|
|
||||||
// Set to connecting
|
// Set to connecting
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
instances: state.instances.map(i =>
|
instances: state.instances.map(i =>
|
||||||
@@ -332,7 +341,36 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reauthenticateInstance: async (origin: string, password: string) => {
|
||||||
|
const inst = get().instances.find(i => i.origin === origin);
|
||||||
|
if (!inst) return;
|
||||||
|
|
||||||
|
// Remove the stale placeholder
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.filter(i => i.origin !== origin),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Remove its spaces from the space store
|
||||||
|
useSpaceStore.getState().removeInstanceSpaces(origin);
|
||||||
|
|
||||||
|
// Disconnect any lingering WS
|
||||||
|
disconnectInstance(origin);
|
||||||
|
|
||||||
|
// Re-connect through the standard flow (handles register/login)
|
||||||
|
const currentUser = useAuthStore.getState().user;
|
||||||
|
await get().connectToRemote(
|
||||||
|
origin,
|
||||||
|
password,
|
||||||
|
currentUser?.displayName || undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
syncInstanceList: async () => {
|
syncInstanceList: async () => {
|
||||||
|
// Prevent premature sync before autoConnectAll has populated all server-known
|
||||||
|
// instances — otherwise a user action (add/remove) would overwrite the server
|
||||||
|
// record with only the currently-loaded subset, permanently erasing the rest
|
||||||
|
if (!get()._autoConnectDone) return;
|
||||||
|
|
||||||
const { instances } = get();
|
const { instances } = get();
|
||||||
const currentUser = useAuthStore.getState().user;
|
const currentUser = useAuthStore.getState().user;
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
@@ -362,23 +400,52 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
autoConnectAll: async () => {
|
autoConnectAll: async () => {
|
||||||
const currentUser = useAuthStore.getState().user;
|
const currentUser = useAuthStore.getState().user;
|
||||||
if (!currentUser || currentUser.replicatedInstances.length === 0) return;
|
if (!currentUser || currentUser.replicatedInstances.length === 0) {
|
||||||
|
set({ _autoConnectDone: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cached = loadCachedTokens();
|
const cached = loadCachedTokens();
|
||||||
const toConnect = currentUser.replicatedInstances.filter(ri => {
|
|
||||||
|
// Split server-known instances into two groups:
|
||||||
|
// - withToken: have a cached token → attempt reconnection
|
||||||
|
// - withoutToken: no cached token → add as error placeholder
|
||||||
|
const withToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0]; entry: CachedInstanceToken }> = [];
|
||||||
|
const withoutToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0] }> = [];
|
||||||
|
|
||||||
|
for (const ri of currentUser.replicatedInstances) {
|
||||||
const origin = ri.origin || `https://${ri.domain}`;
|
const origin = ri.origin || `https://${ri.domain}`;
|
||||||
// Only attempt if we have a cached token and aren't already connected
|
if (get().instances.some(i => i.origin === origin)) continue; // already loaded
|
||||||
return cached[origin] && !get().instances.some(i => i.origin === origin);
|
const entry = cached[origin];
|
||||||
|
if (entry) {
|
||||||
|
withToken.push({ origin, ri, entry });
|
||||||
|
} else {
|
||||||
|
withoutToken.push({ origin, ri });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately add tokenless placeholders so they're visible in Zustand
|
||||||
|
// (and therefore won't be erased by syncInstanceList)
|
||||||
|
if (withoutToken.length > 0) {
|
||||||
|
set((state) => {
|
||||||
|
const placeholders: ConnectedInstance[] = withoutToken.map(({ origin, ri }) => ({
|
||||||
|
origin,
|
||||||
|
label: new URL(origin).host,
|
||||||
|
token: '',
|
||||||
|
user: currentUser, // placeholder
|
||||||
|
username: ri.username,
|
||||||
|
status: 'error' as const,
|
||||||
|
error: 'Session expired — re-authenticate to reconnect',
|
||||||
|
api: createApiClient(origin, () => null),
|
||||||
|
}));
|
||||||
|
return { instances: [...state.instances, ...placeholders] };
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (toConnect.length === 0) return;
|
// Connect instances with cached tokens in parallel
|
||||||
|
if (withToken.length > 0) {
|
||||||
// Connect all in parallel, individual failures are non-blocking
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
toConnect.map(async (ri) => {
|
withToken.map(async ({ origin, ri, entry: cachedEntry }) => {
|
||||||
const origin = ri.origin || `https://${ri.domain}`;
|
|
||||||
const cachedEntry = cached[origin]!; // Guaranteed by filter above
|
|
||||||
|
|
||||||
// Create client with cached token
|
// Create client with cached token
|
||||||
const client = createApiClient(origin, () => cachedEntry.token);
|
const client = createApiClient(origin, () => cachedEntry.token);
|
||||||
|
|
||||||
@@ -467,15 +534,19 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Log any failures for debugging
|
||||||
|
const failures = results.filter(r => r.status === 'rejected');
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.warn(`autoConnectAll: ${failures.length}/${withToken.length} instances failed to connect`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save final state to localStorage — persist ALL instances regardless of status
|
// Save final state to localStorage — persist ALL instances regardless of status
|
||||||
// so disconnected instances survive page reload and can auto-reconnect later
|
// so disconnected instances survive page reload and can auto-reconnect later
|
||||||
saveCachedTokens(get().instances);
|
saveCachedTokens(get().instances);
|
||||||
|
|
||||||
// Log any failures for debugging
|
// Mark auto-connect complete so syncInstanceList is now safe to run
|
||||||
const failures = results.filter(r => r.status === 'rejected');
|
set({ _autoConnectDone: true });
|
||||||
if (failures.length > 0) {
|
|
||||||
console.warn(`autoConnectAll: ${failures.length}/${toConnect.length} instances failed to connect`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
@@ -488,7 +559,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
// Tear down all remote WebSocket connections
|
// Tear down all remote WebSocket connections
|
||||||
disconnectAllRemote();
|
disconnectAllRemote();
|
||||||
|
|
||||||
set({ instances: [], isLoading: false, error: null });
|
set({ instances: [], isLoading: false, error: null, _autoConnectDone: false });
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Space, Channel, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest } from '@backspace/shared';
|
import type { Space, Channel, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared';
|
||||||
import { api, BackspaceApiClient } from '../api/client';
|
import { api, BackspaceApiClient } from '../api/client';
|
||||||
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
||||||
import { isSelf } from '../utils/identity';
|
import { isSelf } from '../utils/identity';
|
||||||
@@ -49,7 +49,7 @@ interface SpaceState {
|
|||||||
loadSpaces: () => Promise<void>;
|
loadSpaces: () => Promise<void>;
|
||||||
loadSpaceDetail: (spaceId: string) => Promise<void>;
|
loadSpaceDetail: (spaceId: string) => Promise<void>;
|
||||||
loadDmChannels: () => Promise<void>;
|
loadDmChannels: () => Promise<void>;
|
||||||
createSpace: (name: string, icon?: string) => Promise<Space>;
|
createSpace: (data: CreateSpaceRequest) => Promise<Space>;
|
||||||
updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise<void>;
|
updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise<void>;
|
||||||
deleteSpace: (spaceId: string) => Promise<void>;
|
deleteSpace: (spaceId: string) => Promise<void>;
|
||||||
joinSpace: (spaceId: string, inviteCode: string) => Promise<void>;
|
joinSpace: (spaceId: string, inviteCode: string) => Promise<void>;
|
||||||
@@ -156,11 +156,26 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
normalizeUserAssets(member.user, origin);
|
normalizeUserAssets(member.user, origin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate permission maps from REST response
|
||||||
|
const spacePermissions = new Map(get().spacePermissions);
|
||||||
|
const channelPermissions = new Map(get().channelPermissions);
|
||||||
|
if (detail.myPermissions) {
|
||||||
|
spacePermissions.set(spaceId, detail.myPermissions);
|
||||||
|
}
|
||||||
|
for (const ch of detail.channels) {
|
||||||
|
if (ch.myPermissions) {
|
||||||
|
channelPermissions.set(ch.id, ch.myPermissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
currentSpaceId: spaceId,
|
currentSpaceId: spaceId,
|
||||||
channels: detail.channels.sort((a, b) => a.position - b.position),
|
channels: detail.channels.sort((a, b) => a.position - b.position),
|
||||||
members: detail.members,
|
members: detail.members,
|
||||||
roles: detail.roles.sort((a, b) => b.position - a.position),
|
roles: detail.roles.sort((a, b) => b.position - a.position),
|
||||||
|
spacePermissions,
|
||||||
|
channelPermissions,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Handle error silently
|
// Handle error silently
|
||||||
@@ -176,8 +191,8 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createSpace: async (name: string, icon?: string) => {
|
createSpace: async (data: CreateSpaceRequest) => {
|
||||||
const space =await api.spaces.create({ name, icon });
|
const space = await api.spaces.create(data);
|
||||||
const tagged: TaggedSpace = { ...space, _instanceOrigin: '' };
|
const tagged: TaggedSpace = { ...space, _instanceOrigin: '' };
|
||||||
set((state) => ({ spaces: [...state.spaces, tagged] }));
|
set((state) => ({ spaces: [...state.spaces, tagged] }));
|
||||||
return space;
|
return space;
|
||||||
|
|||||||
Reference in New Issue
Block a user