feat: replace browser login with imported Reddit sessions

This commit is contained in:
jjbrunton committed 2026-08-08 16:48:59 +01:00
1 parent 2b0545afcf
commit c483c7b8be
76 files changed
+2048 -2828

No files matched your search

+2 -3
View File
@@ -7,6 +7,7 @@ import {
type ControlResponse,
type Timers,
} from './contracts.js';
import { createDashboardRequest } from './request.js';
export class ApiError extends Error {
constructor(
@@ -39,9 +40,7 @@ export interface DashboardApi {
}
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(input, init);
}
const request = createDashboardRequest(fetchImplementation);
return {
async getStats() {
+19
View File
@@ -0,0 +1,19 @@
export type DashboardRequest = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
function redirectToLogin(): void {
globalThis.location.assign('/auth/login');
}
export function createDashboardRequest(
fetchImplementation: typeof fetch | undefined = undefined,
onUnauthorized: () => void = redirectToLogin,
): DashboardRequest {
return async (input, init) => {
const fetchRequest = fetchImplementation ?? globalThis.fetch;
const response = await fetchRequest(input, { credentials: 'same-origin', ...init });
if (response.status === 401) onUnauthorized();
return response;
};
}
export const dashboardRequest = createDashboardRequest();
@@ -14,6 +14,7 @@ import {
type ProcessedPost,
type SuccessResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
@@ -39,9 +40,7 @@ export interface ConversationApi {
}
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(input, init);
}
const request = createDashboardRequest(fetchImplementation);
return {
async list() {
+2 -1
View File
@@ -7,6 +7,7 @@ import {
type MediaItem,
type MediaSyncStatus,
} from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
@@ -18,7 +19,7 @@ async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise
}
function request(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
return dashboardRequest(path, init);
}
export async function fetchMedia(): Promise<MediaItem[]> {
@@ -5,6 +5,7 @@ import {
ChatSyncTriggerResponseSchema,
FetchAllChatsResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
export const RawLogEntrySchema = z.strictObject({
timestamp: z.number(),
@@ -34,9 +35,7 @@ async function parseResponse<T>(
}
export function createOperationsApi(fetchImplementation?: typeof fetch) {
function request(path: string, init?: RequestInit): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path, init);
}
const request = createDashboardRequest(fetchImplementation);
return {
async getActivity() {
return parseResponse(await request('/api/activity'), ActivityResponseSchema);
@@ -148,7 +148,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
</section>
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button disabled={syncBusy} onClick={() => confirmAndRun('full')} type="button">Full cache sync</button><button disabled={syncBusy} onClick={() => confirmAndRun('fetch-all')} type="button">Fetch all chats</button></div></>}
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button className="button secondary" disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('full')} type="button">Full cache sync</button><button className="button secondary" disabled={syncBusy} onClick={() => confirmAndRun('fetch-all')} type="button">Fetch all chats</button></div></>}
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js';
async function loadRecentMessages(): Promise<SentMessage[]> {
const response = await fetch('/api/messages');
const response = await dashboardRequest('/api/messages');
const payload: unknown = await response.json();
if (!response.ok) throw new Error(`Could not load recent messages (${response.status})`);
return MessagesResponseSchema.parse(payload);
@@ -18,7 +19,7 @@ export function RecentMessagesPanel() {
<section aria-labelledby="recent-messages-heading" className="panel">
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
{query.isPending ? <p role="status">Loading recent messages…</p> : null}
{query.isError ? <div role="alert"><span>{query.error.message}</span><button onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null}
{query.isError ? <div role="alert"><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null}
{query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null}
{query.data && query.data.length > 0 ? (
<ol aria-label="Recent sent messages" className="activity-list">
@@ -5,6 +5,7 @@ import {
SuccessfulExamplesResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import type { z } from 'zod';
import { createDashboardRequest } from '../../api/request.js';
type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseSchema>;
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
@@ -39,9 +40,7 @@ export interface PerformanceApi {
export function createPerformanceApi(
fetchImplementation?: typeof fetch,
) {
function request(path: string): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path);
}
const request = createDashboardRequest(fetchImplementation);
return {
async getSubredditPerformance() {
return parseResponse(
@@ -1,4 +1,5 @@
import { useLoginInfoQuery, useLoginStatusQuery, useStartLoginMutation } from './settings-queries.js';
import { useRef } from 'react';
import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
@@ -16,44 +17,50 @@ function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): s
}[status.status];
}
function noVncUrl(publicUrl: string | null, port: number): string {
if (publicUrl) return publicUrl;
return `${window.location.protocol}//${window.location.hostname}:${port}/vnc.html?autoconnect=true&resize=scale`;
}
export function AccessPanel() {
const infoQuery = useLoginInfoQuery();
const statusQuery = useLoginStatusQuery();
const loginMutation = useStartLoginMutation();
const importMutation = useImportRedditSessionMutation();
const importInput = useRef<HTMLInputElement>(null);
if (infoQuery.isPending || statusQuery.isPending) {
async function importSession(file: File | undefined) {
if (!file) return;
try {
const exported: unknown = JSON.parse(await file.text());
importMutation.mutate(exported);
} catch {
importMutation.reset();
importMutation.mutate({});
}
}
if (statusQuery.isPending) {
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access…</section>;
}
if (infoQuery.isError || statusQuery.isError) {
const error = infoQuery.error ?? statusQuery.error;
if (statusQuery.isError) {
return (
<section className="panel error-panel" role="alert">
<strong>Reddit access unavailable</strong><span>{errorMessage(error)}</span>
<button className="button secondary" onClick={() => void Promise.all([infoQuery.refetch(), statusQuery.refetch()])} type="button">Retry access</button>
<strong>Reddit access unavailable</strong><span>{errorMessage(statusQuery.error)}</span>
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry access</button>
</section>
);
}
const info = infoQuery.data;
const session = statusQuery.data;
const username = session.ready && 'loggedIn' in session && session.loggedIn ? session.username ?? null : null;
return (
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Browser session</span><h2 id="access-heading">Access</h2></div><span className={statusQuery.data.ready && 'status' in statusQuery.data && statusQuery.data.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(statusQuery.data)}</span></div>
<p className="settings-copy">Open Reddit in the managed browser, complete login through noVNC, then recheck the session. Credentials are never entered or displayed here.</p>
<div className="panel-heading"><div><span className="eyebrow">Reddit session</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
<p className="settings-copy">When Reddit is logged out, account work is paused and waiting for a fresh session dump from the extension.</p>
{username ? <p className="settings-copy">Signed in as <strong>u/{username}</strong>.</p> : null}
<div className="access-actions">
<button className="button primary" disabled={loginMutation.isPending} onClick={() => loginMutation.mutate()} type="button">{loginMutation.isPending ? 'Opening login…' : 'Open Reddit login'}</button>
<input accept="application/json" aria-label="Reddit session export" className="sr-only" onChange={(event) => void importSession(event.target.files?.[0])} ref={importInput} type="file" />
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Importing session…' : 'Import Reddit session'}</button>
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking session…' : 'Recheck session'}</button>
{info.vncEnabled ? <a className="button secondary" href={noVncUrl(info.publicUrl, info.novncPort)} rel="noreferrer" target="_blank">Open noVNC login window</a> : null}
</div>
{!info.vncEnabled ? <div className="settings-notice warning" role="status">noVNC is not available in this deployment.</div> : null}
{info.passwordRequired ? <p className="settings-copy">A VNC password is required.</p> : null}
{loginMutation.isSuccess ? <div className="settings-notice success" role="status">Login page opened.</div> : null}
{loginMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(loginMutation.error)}</div> : null}
<p className="settings-copy">Open Reddit Chat in Chrome, wait for the extension badge to show OK, then upload its JSON dump here. It contains login credentials; do not share it.</p>
{importMutation.isSuccess ? <div className="settings-notice success" role="status">Reddit session imported and verified.</div> : null}
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
</section>
);
}
@@ -4,13 +4,14 @@ import {
ConfigUpdateRequestSchema,
ConfigUpdateResponseSchema,
ErrorResponseSchema,
LoginInfoResponseSchema,
LoginStartResponseSchema,
LoginStatusResponseSchema,
RedditCookieImportRequestSchema,
RedditCookieImportResponseSchema,
type ConfigResponse,
type ConfigUpdateRequest,
type LoginStatusResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
@@ -22,7 +23,7 @@ async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise
}
function request(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
return dashboardRequest(path, init);
}
export function fetchSettings(): Promise<ConfigResponse> {
@@ -41,18 +42,19 @@ export async function saveSettings(updates: ConfigUpdateRequest) {
);
}
export function fetchLoginInfo() {
return request('/api/login/info').then((response) => parseResponse(response, LoginInfoResponseSchema));
}
export function fetchLoginStatus(): Promise<LoginStatusResponse> {
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
}
export async function startLogin(): Promise<void> {
export async function importRedditSession(exported: unknown): Promise<void> {
const body = RedditCookieImportRequestSchema.parse(exported);
const response = await parseResponse(
await request('/api/login/start', { method: 'POST' }),
LoginStartResponseSchema,
await request('/api/login/session-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
RedditCookieImportResponseSchema,
);
if (!response.ok) throw new Error(response.error);
}
@@ -1,10 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginInfo, fetchLoginStatus, fetchSettings, saveSettings, startLogin } from './settings-api.js';
import { fetchLoginStatus, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
export const settingsQueryKeys = {
config: ['dashboard', 'config'] as const,
loginInfo: ['dashboard', 'login', 'info'] as const,
loginStatus: ['dashboard', 'login', 'status'] as const,
};
@@ -26,18 +25,14 @@ export function useSaveSettingsMutation() {
});
}
export function useLoginInfoQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginInfo, queryFn: fetchLoginInfo });
}
export function useLoginStatusQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus });
}
export function useStartLoginMutation() {
export function useImportRedditSessionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: startLogin,
mutationFn: importRedditSession,
onSuccess() {
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
},