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
314 changes: 314 additions & 0 deletions docs/specs/simadmin/00-implementation-reference.md

Large diffs are not rendered by default.

735 changes: 735 additions & 0 deletions docs/specs/simadmin/01-esim-and-sim-identity.md

Large diffs are not rendered by default.

610 changes: 610 additions & 0 deletions docs/specs/simadmin/02-system-event-notifications.md

Large diffs are not rendered by default.

639 changes: 639 additions & 0 deletions docs/specs/simadmin/03-modem-watchdog-and-recovery.md

Large diffs are not rendered by default.

696 changes: 696 additions & 0 deletions docs/specs/simadmin/04-automation-scheduler.md

Large diffs are not rendered by default.

855 changes: 855 additions & 0 deletions docs/specs/simadmin/05-backup-and-restore.md

Large diffs are not rendered by default.

659 changes: 659 additions & 0 deletions docs/specs/simadmin/06-cellular-network-management.md

Large diffs are not rendered by default.

53 changes: 48 additions & 5 deletions frontend/src/components/messages/message-console.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export function MessageConsole({
? `${initialPhone}:${targetMessageId}`
: null;
const markingReadPhonesRef = useRef<Set<string>>(new Set());
const markingReadPromisesRef = useRef<Map<string, Promise<void>>>(new Map());
const manualUnreadMessageIdsRef = useRef<Map<string, Set<number>>>(new Map());
const hasLoadedConversationsRef = useRef(false);
const loadedMessageCountRef = useRef(0);
const pendingSendRef = useRef<{
Expand Down Expand Up @@ -562,20 +564,33 @@ export function MessageConsole({
useEffect(() => {
if (!selectedPhone || isComposingNew) return;
if (markingReadPhonesRef.current.has(selectedPhone)) return;
const hasUnread = messages.some(
const unreadMessages = messages.filter(
(message) =>
message.phone_number === selectedPhone &&
message.direction === "inbound" &&
message.read_at === null,
);
if (!hasUnread) return;
if (unreadMessages.length === 0) return;

const phone = selectedPhone;
const manualUnreadIds = manualUnreadMessageIdsRef.current.get(phone);
const autoReadMessages = manualUnreadIds
? unreadMessages.filter((message) => !manualUnreadIds.has(message.id))
: unreadMessages;
if (autoReadMessages.length === 0) return;
markingReadPhonesRef.current.add(phone);

apiFetch(`/api/conversations/${encodeURIComponent(phone)}/read`, {
method: "POST",
})
const markRead = manualUnreadIds
? Promise.all(
autoReadMessages.map((message) =>
apiFetch(`/api/messages/${message.id}/read`, { method: "POST" }),
),
)
: apiFetch(`/api/conversations/${encodeURIComponent(phone)}/read`, {
method: "POST",
});

const operation = markRead
.then(
() =>
reloadActiveViews().catch((err) => {
Expand All @@ -589,7 +604,11 @@ export function MessageConsole({
)
.finally(() => {
markingReadPhonesRef.current.delete(phone);
if (markingReadPromisesRef.current.get(phone) === operation) {
markingReadPromisesRef.current.delete(phone);
}
});
markingReadPromisesRef.current.set(phone, operation);
}, [isComposingNew, messages, reloadActiveViews, selectedPhone]);

const filteredConversations = useMemo(() => {
Expand Down Expand Up @@ -628,6 +647,7 @@ export function MessageConsole({

function selectConversation(phone: string) {
if (!isComposingNew && phone === selectedPhone) return;
manualUnreadMessageIdsRef.current.delete(phone);
resetMessageWindow();
setSelectedPhone(phone);
setPhoneNumber(phone);
Expand All @@ -637,6 +657,9 @@ export function MessageConsole({
}

function startNewMessage() {
if (selectedPhone) {
manualUnreadMessageIdsRef.current.delete(selectedPhone);
}
setSelectedPhone(null);
setPhoneNumber("");
resetMessageWindow();
Expand All @@ -646,6 +669,9 @@ export function MessageConsole({
}

function closeMobileThread() {
if (selectedPhone) {
manualUnreadMessageIdsRef.current.delete(selectedPhone);
}
resetMessageWindow();
setIsComposingNew(false);
setSelectedPhone(null);
Expand Down Expand Up @@ -725,6 +751,7 @@ export function MessageConsole({
console.error(err);
return;
}
manualUnreadMessageIdsRef.current.delete(selectedPhone);
try {
await reloadActiveViews();
} catch (err) {
Expand All @@ -735,8 +762,23 @@ export function MessageConsole({

async function handleMarkSelected(read: boolean) {
const ids = Array.from(selectedIds);
const phone = selectedPhone;
if (ids.length === 0) return;
setOperationError(null);
if (phone) {
const manualUnreadIds =
manualUnreadMessageIdsRef.current.get(phone) ?? new Set<number>();
for (const id of ids) {
if (read) manualUnreadIds.delete(id);
else manualUnreadIds.add(id);
}
if (manualUnreadIds.size > 0) {
manualUnreadMessageIdsRef.current.set(phone, manualUnreadIds);
} else {
manualUnreadMessageIdsRef.current.delete(phone);
}
await markingReadPromisesRef.current.get(phone);
}
try {
await Promise.all(
ids.map((id) =>
Expand Down Expand Up @@ -852,6 +894,7 @@ export function MessageConsole({
});
setDeleteConversationTarget(null);
if (selectedPhone === phone) {
manualUnreadMessageIdsRef.current.delete(phone);
resetMessageWindow();
setSelectedPhone(null);
setIsComposingNew(false);
Expand Down
199 changes: 199 additions & 0 deletions frontend/src/message-console.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,205 @@ describe("MessageConsole bulk read", () => {
resolveRead?.();
await waitFor(() => expect(markedRead).toBe(true));
});

test("preserves a manually unread message across refreshes while reading new arrivals", async () => {
const readAt = "2026-07-11T00:01:00Z";
let serverMessages = [
{ ...unreadMessage, read_at: readAt as string | null },
];
const mutationRequests: string[] = [];
let resolveUnreadResponse: (() => void) | undefined;
mocks.apiFetch.mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/conversations") {
return Promise.resolve([
{
phone_number: unreadMessage.phone_number,
last_message: serverMessages[0],
unread_count: serverMessages.filter(
(message) => message.read_at === null,
).length,
total_count: serverMessages.length,
},
]);
}
if (input.startsWith("/api/messages?") && init?.method !== "POST") {
return Promise.resolve(
serverMessages.map((message) => ({ ...message })),
);
}
if (init?.method === "POST") {
mutationRequests.push(input);
if (input === `/api/messages/${unreadMessage.id}/unread`) {
serverMessages = serverMessages.map((message) =>
message.id === unreadMessage.id
? { ...message, read_at: null }
: message,
);
mocks.handlers["message.read_state_changed"]?.();
return new Promise((resolve) => {
resolveUnreadResponse = () => resolve({});
});
}
const readMatch = input.match(/^\/api\/messages\/(\d+)\/read$/);
if (readMatch) {
const id = Number(readMatch[1]);
serverMessages = serverMessages.map((message) =>
message.id === id ? { ...message, read_at: readAt } : message,
);
}
if (input.includes("/api/conversations/")) {
serverMessages = serverMessages.map((message) => ({
...message,
read_at: readAt,
}));
}
return Promise.resolve({});
}
return Promise.resolve({});
});

render(<MessageConsole />);
fireEvent.click(
await screen.findByRole("button", { name: /\+15550000001/ }),
);
await screen.findByText("test body");
fireEvent.click(
screen.getByRole("button", { name: "Conversation actions" }),
);
fireEvent.click(
await screen.findByRole("menuitem", { name: "Select messages" }),
);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(
screen.getByRole("button", { name: "Conversation actions" }),
);
fireEvent.click(
await screen.findByRole("menuitem", { name: "Mark unread (1)" }),
);

await waitFor(() =>
expect(mutationRequests).toContain(
`/api/messages/${unreadMessage.id}/unread`,
),
);
await new Promise((resolve) => setTimeout(resolve, 150));
expect(
serverMessages.find((message) => message.id === 1)?.read_at,
).toBeNull();
expect(
mutationRequests.some((request) =>
request.includes("/api/conversations/"),
),
).toBe(false);
resolveUnreadResponse?.();
await waitFor(() => expect(screen.queryByRole("checkbox")).toBeNull());
mocks.handlers["message.read_state_changed"]?.();
const newMessage = {
...unreadMessage,
id: 2,
body: "new arrival",
read_at: null,
};
serverMessages = [newMessage, ...serverMessages];
mocks.handlers["message.created"]?.({ payload: newMessage });

await waitFor(() =>
expect(mutationRequests).toContain(`/api/messages/${newMessage.id}/read`),
);
expect(
serverMessages.find((message) => message.id === 1)?.read_at,
).toBeNull();
expect(serverMessages.find((message) => message.id === 2)?.read_at).toBe(
readAt,
);
expect(
mutationRequests.some((request) =>
request.includes("/api/conversations/"),
),
).toBe(false);
});

test("applies manual unread after an in-flight automatic read", async () => {
const readAt = "2026-07-11T00:01:00Z";
let serverMessage = {
...unreadMessage,
read_at: null as string | null,
};
const mutationRequests: string[] = [];
let resolveAutomaticRead: (() => void) | undefined;
mocks.apiFetch.mockImplementation((input: string, init?: RequestInit) => {
if (input === "/api/conversations") {
return Promise.resolve([
{
phone_number: unreadMessage.phone_number,
last_message: serverMessage,
unread_count: serverMessage.read_at === null ? 1 : 0,
total_count: 1,
},
]);
}
if (input.startsWith("/api/messages?") && init?.method !== "POST") {
return Promise.resolve([{ ...serverMessage }]);
}
if (init?.method === "POST") {
mutationRequests.push(input);
if (input.includes("/api/conversations/")) {
return new Promise((resolve) => {
resolveAutomaticRead = () => {
serverMessage = { ...serverMessage, read_at: readAt };
resolve({});
};
});
}
if (input === `/api/messages/${unreadMessage.id}/unread`) {
serverMessage = { ...serverMessage, read_at: null };
}
return Promise.resolve({});
}
return Promise.resolve({});
});

render(<MessageConsole />);
fireEvent.click(
await screen.findByRole("button", { name: /\+15550000001/ }),
);
await waitFor(() =>
expect(
mutationRequests.some((request) =>
request.includes("/api/conversations/"),
),
).toBe(true),
);
fireEvent.click(
screen.getByRole("button", { name: "Conversation actions" }),
);
fireEvent.click(
await screen.findByRole("menuitem", { name: "Select messages" }),
);
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(
screen.getByRole("button", { name: "Conversation actions" }),
);
fireEvent.click(
await screen.findByRole("menuitem", { name: "Mark unread (1)" }),
);

await Promise.resolve();
expect(mutationRequests).not.toContain(
`/api/messages/${unreadMessage.id}/unread`,
);
resolveAutomaticRead?.();
await waitFor(() =>
expect(mutationRequests).toContain(
`/api/messages/${unreadMessage.id}/unread`,
),
);
expect(serverMessage.read_at).toBeNull();
expect(mutationRequests).toEqual([
expect.stringContaining("/api/conversations/"),
`/api/messages/${unreadMessage.id}/unread`,
]);
});
});

describe("MessageConsole timeline pagination", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/api/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const MAX_LOGIN_FAILURES: u32 = 5;
const LOGIN_FAILURE_WINDOW: StdDuration = StdDuration::from_secs(5 * 60);
const CREDENTIAL_SECRET_BYTES: usize = 32;

#[derive(Clone)]
pub(super) struct AuthenticatedSession(pub String);

#[derive(Clone, Copy)]
struct LoginFailures {
window_started: Instant,
Expand Down
Loading
Loading