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
5 changes: 4 additions & 1 deletion tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,12 @@ def test_invalidate_auth_resets_auth_state_and_restarts(self):
change_state=events.append,
)

TUIManager(twitch)._invalidate_auth()
manager = TUIManager(twitch)
manager._invalidate_auth()

self.assertEqual(events, ["invalidate", State.RESTART])
self.assertEqual(manager.state.login.status, "Login required")
self.assertEqual(manager.state.login.user_id, "-")

def test_inventory_snapshot_reads_settings_from_manager(self):
settings = SimpleNamespace(
Expand Down
28 changes: 27 additions & 1 deletion tests/test_web_auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import tempfile
import unittest
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from pathlib import Path
from types import SimpleNamespace

Expand Down Expand Up @@ -79,6 +79,32 @@ async def test_api_responses_are_never_cached(self):
response = await client.get("/api/session")
self.assertEqual(response.headers["Cache-Control"], "no-store")

async def test_twitch_login_can_be_reset_while_miner_is_stopped(self):
with tempfile.TemporaryDirectory() as directory:
auth_path = Path(directory, "auth.sqlite3")
cookies_path = Path(directory, "cookies.jar")
cookies_path.write_text("saved Twitch session", encoding="utf8")
AuthStore(auth_path).provision(
"correct horse battery", "recovery-code-long-enough"
)
app = create_app(auth_path, Path(directory), auto_start=False)
controller = app["controller"]
controller.start = AsyncMock(return_value=True)
with patch("web.controller.COOKIES_PATH", cookies_path):
async with TestClient(TestServer(app)) as client:
login = await client.post(
"/api/login", json={"password": "correct horse battery"}
)
csrf = (await login.json())["csrf_token"]
response = await client.post(
"/api/miner/invalidate-auth",
headers={"X-CSRF-Token": csrf},
)

self.assertEqual(response.status, 200)
self.assertFalse(cookies_path.exists())
controller.start.assert_awaited_once()


if __name__ == "__main__":
unittest.main()
2 changes: 2 additions & 0 deletions tui/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ def _reload(self) -> None:

def _invalidate_auth(self) -> None:
self._twitch._auth_state.invalidate()
self.state.login = LoginSnapshot(status=_("gui", "login", "required"))
self.refresh_login()
self._twitch.change_state(State.RESTART)

def _switch_channel(self) -> None:
Expand Down
11 changes: 10 additions & 1 deletion web/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from argparse import Namespace
from typing import Any

from core.constants import FILE_FORMATTER, LOCK_PATH, LOG_PATH
from core.constants import COOKIES_PATH, FILE_FORMATTER, LOCK_PATH, LOG_PATH
from core.exceptions import CaptchaRequired
from core.settings import Settings
from core.translate import _
Expand Down Expand Up @@ -61,6 +61,15 @@ async def close(self) -> None:
if self.running:
await self.stop(notify=False)

async def reset_auth(self) -> bool:
if self.running:
if self.manager is None:
return False
self.manager.invalidate_auth()
return True
COOKIES_PATH.unlink(missing_ok=True)
return await self.start()

async def _run(self) -> None:
success, instance_lock = lock_file(LOCK_PATH)
if not success:
Expand Down
16 changes: 11 additions & 5 deletions web/frontend/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,10 @@ export function Dashboard({ session, onSignedOut }: Props) {
try {
await api(path, options)
await load(true)
return true
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Action failed.")
return false
} finally {
setBusy("")
}
Expand All @@ -205,6 +207,10 @@ export function Dashboard({ session, onSignedOut }: Props) {
onSignedOut()
}

async function resetTwitch() {
if (await runAction("invalidate", "/api/miner/invalidate-auth")) setTab("overview")
}

function changeSetting<K extends keyof Settings>(key: K, value: Settings[K]) {
setSettingsDraft((current) => ({ ...current, [key]: value }))
setSettingsDirty(true)
Expand Down Expand Up @@ -350,13 +356,13 @@ export function Dashboard({ session, onSignedOut }: Props) {
</div>
</section>

{state.login.activation_url && (
{state.miner.running && state.login.user_id === "-" && (
<section className="rounded-2xl border border-orange-500/25 bg-orange-500/8 p-5 sm:flex sm:items-center sm:justify-between sm:gap-6">
<div>
<p className="font-semibold">Connect Twitch</p>
<p className="mt-1 text-sm text-muted-foreground">Open Twitch activation and enter code <strong className="text-foreground">{state.login.user_code}</strong>.</p>
<p className="font-semibold">{state.login.activation_url ? "Connect Twitch" : "Preparing Twitch login"}</p>
<p className="mt-1 text-sm text-muted-foreground">{state.login.activation_url ? <>Open Twitch activation and enter code <strong className="text-foreground">{state.login.user_code}</strong>.</> : "Waiting for Twitch’s authorization service. The activation code will appear here automatically."}</p>
</div>
<a className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground sm:mt-0" href={state.login.activation_url} rel="noreferrer" target="_blank">Open Twitch<LinkSimpleIcon /></a>
{state.login.activation_url && <a className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground sm:mt-0" href={state.login.activation_url} rel="noreferrer" target="_blank">Open Twitch<LinkSimpleIcon /></a>}
</section>
)}

Expand Down Expand Up @@ -388,7 +394,7 @@ export function Dashboard({ session, onSignedOut }: Props) {
<TabsContent value="campaigns" className="min-h-0 overflow-hidden pt-5"><Campaigns campaigns={state.campaigns} /></TabsContent>
<TabsContent value="channels" className="min-h-0 overflow-hidden pt-5"><Channels state={state} busy={busy} onSelect={(id) => runAction("channel", "/api/channels/select", { method: "POST", body: JSON.stringify({ channel_id: id }) })} /></TabsContent>
<TabsContent value="games" className="min-h-0 overflow-hidden pt-5"><GameRules draft={settingsDraft} dirty={settingsDirty} busy={busy} onChange={changeSetting} onSave={saveSettings} /></TabsContent>
<TabsContent value="settings" className="min-h-0 overflow-hidden pt-5"><SettingsPanel draft={settingsDraft} dirty={settingsDirty} busy={busy} session={session} notifications={state.notifications} notificationDraft={notificationDraft} notificationDirty={notificationDirty} notificationMessage={notificationMessage} onSignedOut={onSignedOut} onChange={changeSetting} onNotificationChange={changeNotification} onSave={saveSettings} onSaveNotifications={saveNotifications} onTestNotifications={testNotifications} onRemoveNotifications={removeNotifications} onInvalidate={() => runAction("invalidate", "/api/miner/invalidate-auth")} /></TabsContent>
<TabsContent value="settings" className="min-h-0 overflow-hidden pt-5"><SettingsPanel draft={settingsDraft} dirty={settingsDirty} busy={busy} session={session} notifications={state.notifications} notificationDraft={notificationDraft} notificationDirty={notificationDirty} notificationMessage={notificationMessage} onSignedOut={onSignedOut} onChange={changeSetting} onNotificationChange={changeNotification} onSave={saveSettings} onSaveNotifications={saveNotifications} onTestNotifications={testNotifications} onRemoveNotifications={removeNotifications} onInvalidate={resetTwitch} /></TabsContent>
<TabsContent value="logs" className="min-h-0 overflow-hidden pt-5"><Logs logs={state.logs} /></TabsContent>
</Tabs>

Expand Down
13 changes: 8 additions & 5 deletions web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,14 +232,17 @@ async def miner_action(request: web.Request) -> web.Response:
session = await _session(request, csrf=True)
if isinstance(session, web.Response):
return session
manager = request.app["controller"].manager
if manager is None or not request.app["controller"].running:
return _json_error("Miner is not running.", 409)
controller = request.app["controller"]
action = request.match_info["action"]
if action == "invalidate-auth":
if not await controller.reset_auth():
return _json_error("Miner is still starting. Try again shortly.", 409)
return web.json_response({"ok": True})
manager = controller.manager
if manager is None or not controller.running:
return _json_error("Miner is not running.", 409)
if action == "reload":
manager.reload()
elif action == "invalidate-auth":
manager.invalidate_auth()
else:
return _json_error("Unknown miner action.", 404)
return web.json_response({"ok": True})
Expand Down
10 changes: 0 additions & 10 deletions web/static/assets/index-B6UzHolg.js

This file was deleted.

10 changes: 10 additions & 0 deletions web/static/assets/index-dTDArlMN.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<meta property="og:description" content="Private self-hosted Twitch drops control room." />
<link rel="icon" type="image/png" href="/favicon-v2.png" />
<title>DropForge</title>
<script type="module" crossorigin src="/assets/index-B6UzHolg.js"></script>
<script type="module" crossorigin src="/assets/index-dTDArlMN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ch2SycPi.css">
</head>
<body>
Expand Down
Loading