feat: replace browser login with imported Reddit sessions
This commit is contained in:
1 parent
2b0545afcf
commit
c483c7b8be
76 files changed
+2048
-2828
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user